271 lines
9.8 KiB
Python
271 lines
9.8 KiB
Python
"""
|
|
Tests for stdio-mode custom tool discovery (GitHub issue #837).
|
|
|
|
Verifies that:
|
|
1. sync_tool_visibility_from_unity registers custom tools when extended metadata is present
|
|
2. Custom tools are skipped gracefully when metadata is missing (old Unity package)
|
|
3. Reconnection flag triggers a background re-sync
|
|
"""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_unity_response(tools, include_extended=True):
|
|
"""Build a fake get_tool_states response from Unity."""
|
|
tool_list = []
|
|
for t in tools:
|
|
entry = {
|
|
"name": t["name"],
|
|
"group": t.get("group", "core"),
|
|
"enabled": t.get("enabled", True),
|
|
}
|
|
if include_extended:
|
|
entry.update({
|
|
"description": t.get("description", f"Tool: {t['name']}"),
|
|
"auto_register": t.get("auto_register", True),
|
|
"is_built_in": t.get("is_built_in", True),
|
|
"structured_output": t.get("structured_output", False),
|
|
"requires_polling": t.get("requires_polling", False),
|
|
"poll_action": t.get("poll_action", "status"),
|
|
"max_poll_seconds": t.get("max_poll_seconds", 0),
|
|
"parameters": t.get("parameters", []),
|
|
})
|
|
tool_list.append(entry)
|
|
return {
|
|
"data": {
|
|
"tools": tool_list,
|
|
"groups": [],
|
|
}
|
|
}
|
|
|
|
|
|
BUILTIN_TOOL = {
|
|
"name": "manage_gameobject",
|
|
"group": "core",
|
|
"is_built_in": True,
|
|
"description": "Manage GameObjects in the scene.",
|
|
}
|
|
|
|
CUSTOM_TOOL = {
|
|
"name": "test_ping",
|
|
"group": "core",
|
|
"is_built_in": False,
|
|
"description": "Simple test tool that returns a pong.",
|
|
"parameters": [
|
|
{"name": "message", "description": "Message to echo", "type": "string", "required": False, "default_value": "pong"},
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sync_tool_visibility_from_unity — custom tool registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_registers_custom_tools():
|
|
"""Custom (non-built-in) tools should be registered via CustomToolService."""
|
|
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL])
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.register_global_tools = MagicMock()
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.async_send_command_with_retry",
|
|
new_callable=AsyncMock,
|
|
return_value=response,
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
|
new_callable=AsyncMock,
|
|
), patch(
|
|
"services.custom_tool_service.CustomToolService.get_instance",
|
|
return_value=mock_service,
|
|
):
|
|
from services.tools import sync_tool_visibility_from_unity
|
|
result = await sync_tool_visibility_from_unity(notify=False)
|
|
|
|
assert result["synced"] is True
|
|
assert result["custom_tool_count"] == 1
|
|
|
|
# Verify register_global_tools was called with the custom tool
|
|
mock_service.register_global_tools.assert_called_once()
|
|
registered = mock_service.register_global_tools.call_args[0][0]
|
|
assert len(registered) == 1
|
|
assert registered[0].name == "test_ping"
|
|
assert registered[0].description == "Simple test tool that returns a pong."
|
|
assert len(registered[0].parameters) == 1
|
|
assert registered[0].parameters[0].name == "message"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_skips_builtin_tools():
|
|
"""Built-in tools should NOT be passed to register_global_tools."""
|
|
response = _make_unity_response([BUILTIN_TOOL])
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.async_send_command_with_retry",
|
|
new_callable=AsyncMock,
|
|
return_value=response,
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
|
new_callable=AsyncMock,
|
|
), patch(
|
|
"services.custom_tool_service.CustomToolService.get_instance",
|
|
) as mock_get_instance:
|
|
from services.tools import sync_tool_visibility_from_unity
|
|
result = await sync_tool_visibility_from_unity(notify=False)
|
|
|
|
assert result["synced"] is True
|
|
assert result["custom_tool_count"] == 0
|
|
# No custom tools → register_global_tools should NOT be called
|
|
mock_get_instance.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_skips_when_no_extended_metadata():
|
|
"""When Unity returns old-format data (no is_built_in), skip custom tool registration."""
|
|
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL], include_extended=False)
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.async_send_command_with_retry",
|
|
new_callable=AsyncMock,
|
|
return_value=response,
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
|
new_callable=AsyncMock,
|
|
), patch(
|
|
"services.custom_tool_service.CustomToolService.get_instance",
|
|
) as mock_get_instance:
|
|
from services.tools import sync_tool_visibility_from_unity
|
|
result = await sync_tool_visibility_from_unity(notify=False)
|
|
|
|
assert result["synced"] is True
|
|
assert result["custom_tool_count"] == 0
|
|
mock_get_instance.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_handles_custom_tool_service_not_initialized():
|
|
"""If CustomToolService isn't initialized yet, skip gracefully (no crash)."""
|
|
response = _make_unity_response([CUSTOM_TOOL])
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.async_send_command_with_retry",
|
|
new_callable=AsyncMock,
|
|
return_value=response,
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
|
), patch(
|
|
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
|
new_callable=AsyncMock,
|
|
), patch(
|
|
"services.custom_tool_service.CustomToolService.get_instance",
|
|
side_effect=RuntimeError("not initialized"),
|
|
):
|
|
from services.tools import sync_tool_visibility_from_unity
|
|
result = await sync_tool_visibility_from_unity(notify=False)
|
|
|
|
# Should succeed overall even though custom tool registration failed
|
|
assert result["synced"] is True
|
|
assert result["custom_tool_count"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reconnection re-sync trigger
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnection_flag_triggers_resync():
|
|
"""After reconnection, async_send_command_with_retry should schedule a re-sync."""
|
|
mock_conn = MagicMock()
|
|
mock_conn._needs_tool_resync = True
|
|
mock_conn.instance_id = None
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_connection.return_value = mock_conn
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.send_command_with_retry",
|
|
return_value={"success": True, "message": "ok"},
|
|
), patch(
|
|
"transport.legacy.unity_connection.get_unity_connection_pool",
|
|
return_value=mock_pool,
|
|
), patch(
|
|
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
|
new_callable=AsyncMock,
|
|
) as mock_resync:
|
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
result = await async_send_command_with_retry("manage_gameobject", {"action": "list"})
|
|
|
|
# ensure_future schedules on the event loop; give it a tick to run
|
|
await asyncio.sleep(0)
|
|
|
|
assert result["success"] is True
|
|
# Flag should be cleared
|
|
assert mock_conn._needs_tool_resync is False
|
|
# Re-sync should have been scheduled
|
|
mock_resync.assert_awaited_once_with(None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_resync_for_get_tool_states():
|
|
"""get_tool_states itself should NOT trigger re-sync (avoids recursion)."""
|
|
mock_conn = MagicMock()
|
|
mock_conn._needs_tool_resync = True
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_connection.return_value = mock_conn
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.send_command_with_retry",
|
|
return_value={"data": {"tools": []}},
|
|
), patch(
|
|
"transport.legacy.unity_connection.get_unity_connection_pool",
|
|
return_value=mock_pool,
|
|
), patch(
|
|
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
|
new_callable=AsyncMock,
|
|
) as mock_resync:
|
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
await async_send_command_with_retry("get_tool_states", {})
|
|
|
|
# Flag should be cleared, but no re-sync task should be scheduled
|
|
assert mock_conn._needs_tool_resync is False
|
|
mock_resync.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_resync_when_not_reconnected():
|
|
"""When _needs_tool_resync is False, no re-sync should be scheduled."""
|
|
mock_conn = MagicMock()
|
|
mock_conn._needs_tool_resync = False
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_connection.return_value = mock_conn
|
|
|
|
with patch(
|
|
"transport.legacy.unity_connection.send_command_with_retry",
|
|
return_value={"success": True},
|
|
), patch(
|
|
"transport.legacy.unity_connection.get_unity_connection_pool",
|
|
return_value=mock_pool,
|
|
), patch(
|
|
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
|
new_callable=AsyncMock,
|
|
) as mock_resync:
|
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
await async_send_command_with_retry("manage_gameobject", {"action": "list"})
|
|
|
|
mock_resync.assert_not_awaited()
|