File size: 15,708 Bytes
a5784e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
"""
High-quality tests for api_utils/utils.py - Tool execution safety (zero mocking of core logic).

Focus: Test maybe_execute_tools with emphasis on async safety and edge cases.
Strategy: Mock only external boundaries (execute_tool_call, register_runtime_tools).
"""

import asyncio
from typing import List, cast
from unittest.mock import AsyncMock, patch

import pytest

from models import Message, MessageContentItem


@pytest.mark.asyncio
async def test_maybe_execute_tools_cancelled_error_reraised():
    """
    Test scenario: Correctly re-throw CancelledError when function is cancelled
    Expected: CancelledError not swallowed, must be re-thrown
    This is a CRITICAL test - prevents request hang
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [{"function": {"name": "test_tool"}}]
    tool_choice = {"type": "function", "function": {"name": "test_tool"}}

    # Mock execute_tool_call to raise CancelledError - patch where it's imported/used
    with patch(
        "api_utils.utils_ext.tools_execution.execute_tool_call", new_callable=AsyncMock
    ) as mock_exec:
        mock_exec.side_effect = asyncio.CancelledError()
        with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
            # Expected: CancelledError re-thrown
            with pytest.raises(asyncio.CancelledError):
                await maybe_execute_tools(messages, tools, tool_choice)


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_dict_format():
    """
    Test scenario: tool_choice as dictionary format {"type": "function", "function": {"name": "foo"}}
    Expected: Extract function name and execute
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content='{"arg": "value"}')]
    tools = [{"function": {"name": "my_function"}}]
    tool_choice = {"type": "function", "function": {"name": "my_function"}}

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = '{"result": "success"}'

        result = await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: execute_tool_call called with correct parameters
        mock_exec.assert_called_once_with("my_function", '{"arg": "value"}')
        assert result is not None
        assert len(result) == 1
        assert result[0]["name"] == "my_function"
        assert result[0]["arguments"] == '{"arg": "value"}'
        assert result[0]["result"] == '{"result": "success"}'


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_string_none():
    """
    Test scenario: tool_choice as string "none" (case-insensitive)
    Expected: Return None, no tool executed
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [{"function": {"name": "test_tool"}}]

    with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
        for choice in ["none", "None", "NONE", "no", "NO", "off", "OFF"]:
            result = await maybe_execute_tools(messages, tools, choice)
            assert result is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_auto_single_tool():
    """
    Test scenario: tool_choice as "auto" and only one tool
    Expected: Automatically execute that tool
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content='{"x": 1}')]
    tools = [{"function": {"name": "only_tool"}}]

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = '{"done": true}'

        for choice in ["auto", "required", "any"]:
            result = await maybe_execute_tools(messages, tools, choice)

            assert result is not None
            assert result[0]["name"] == "only_tool"
            mock_exec.assert_called_with("only_tool", '{"x": 1}')
            mock_exec.reset_mock()


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_auto_multiple_tools():
    """
    Test scenario: tool_choice as "auto" but multiple tools
    Expected: No tool executed (as automatic choice is not possible)
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [
        {"function": {"name": "tool1"}},
        {"function": {"name": "tool2"}},
    ]

    with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
        result = await maybe_execute_tools(messages, tools, "auto")
        assert result is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_direct_name():
    """
    Test scenario: tool_choice as function name string (direct specification)
    Expected: Execute that function
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content='{"param": 123}')]
    tools = [{"function": {"name": "direct_call"}}]

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = '{"status": "ok"}'

        result = await maybe_execute_tools(messages, tools, "direct_call")

        assert result is not None
        assert result[0]["name"] == "direct_call"
        mock_exec.assert_called_once_with("direct_call", '{"param": 123}')


@pytest.mark.asyncio
async def test_maybe_execute_tools_tool_choice_none():
    """
    Test scenario: tool_choice is None
    Expected: Do not actively execute tool, return None
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [{"function": {"name": "test_tool"}}]

    with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
        result = await maybe_execute_tools(messages, tools, None)
        assert result is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_arguments_from_user_text():
    """
    Test scenario: Extract JSON from the latest user message as parameters
    Expected: Use _extract_json_from_text to extract JSON
    """
    from api_utils.utils import maybe_execute_tools

    messages = [
        Message(role="system", content="System message"),
        Message(role="user", content="First user message"),
        Message(role="assistant", content="Response"),
        Message(
            role="user",
            content='Call function with params: {"key": "value", "num": 42}',
        ),
    ]
    tools = [{"function": {"name": "test_func"}}]
    tool_choice = "test_func"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = "ok"

        result = await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: Parameters extracted as JSON from the last user message
        mock_exec.assert_called_once_with("test_func", '{"key": "value", "num": 42}')
        assert result is not None
        assert result[0]["arguments"] == '{"key": "value", "num": 42}'


@pytest.mark.asyncio
async def test_maybe_execute_tools_arguments_fallback_empty():
    """
    Test scenario: No valid JSON in user message
    Expected: Use empty parameters "{}"
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="No JSON here, just plain text")]
    tools = [{"function": {"name": "my_tool"}}]
    tool_choice = "my_tool"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = "done"

        result = await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: Parameters fall back to empty JSON
        mock_exec.assert_called_once_with("my_tool", "{}")
        assert result is not None
        assert result[0]["arguments"] == "{}"


@pytest.mark.asyncio
async def test_maybe_execute_tools_existing_tool_result_skip():
    """
    Test scenario: Message with role='tool' already in message list
    Expected: No further tool execution, return None (follows conversational call loop)
    """
    from api_utils.utils import maybe_execute_tools

    messages = [
        Message(role="user", content='{"x": 1}'),
        Message(role="assistant", content="Let me call the tool"),
        # Already have tool result message
        Message(role="tool", content='{"result": "previous call"}'),
    ]
    tools = [{"function": {"name": "my_tool"}}]
    tool_choice = "my_tool"

    with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
        result = await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: No execution because tool result already exists
        assert result is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_base_exception_returns_none():
    """
    Test scenario: execute_tool_call throws common exception (non-CancelledError)
    Expected: Catch exception, return None
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content='{"arg": "val"}')]
    tools = [{"function": {"name": "failing_tool"}}]
    tool_choice = "failing_tool"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        # Mock common exception
        mock_exec.side_effect = ValueError("Something went wrong")

        result = await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: Exception caught, return None
        assert result is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_register_runtime_tools_called():
    """
    Test scenario: Verify register_runtime_tools called correctly
    Expected: Register tools on each maybe_execute_tools call
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [{"function": {"name": "tool1"}}, {"function": {"name": "tool2"}}]
    tool_choice = "tool1"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch(
            "api_utils.utils_ext.tools_execution.register_runtime_tools"
        ) as mock_register,
    ):
        mock_exec.return_value = "ok"

        await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: register_runtime_tools called with tools and None (default MCP endpoint)
        mock_register.assert_called_once_with(tools, None)


@pytest.mark.asyncio
async def test_maybe_execute_tools_empty_messages():
    """
    Test scenario: Message list is empty
    Expected: No user text, parameters fall back to "{}"
    """
    from api_utils.utils import maybe_execute_tools

    messages = []
    tools = [{"function": {"name": "test_tool"}}]
    tool_choice = "test_tool"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = "done"

        await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: Parameters are empty JSON
        mock_exec.assert_called_once_with("test_tool", "{}")


@pytest.mark.asyncio
async def test_maybe_execute_tools_no_chosen_name():
    """
    Test scenario: No function name obtained after tool_choice parsing
    Expected: Return None
    """
    from api_utils.utils import maybe_execute_tools

    messages = [Message(role="user", content="test")]
    tools = [{"function": {"name": "test_tool"}}]

    with patch("api_utils.utils_ext.tools_execution.register_runtime_tools"):
        # tool_choice is empty dict, no function.name
        result1 = await maybe_execute_tools(messages, tools, {})
        assert result1 is None

        # tool_choice is dict but function.name missing
        result2 = await maybe_execute_tools(
            messages, tools, {"type": "function", "function": {}}
        )
        assert result2 is None


@pytest.mark.asyncio
async def test_maybe_execute_tools_multiline_json_extraction():
    """
    Test scenario: User message contains multiline JSON
    Expected: Correctly extract multiline JSON
    """
    from api_utils.utils import maybe_execute_tools

    messages = [
        Message(
            role="user",
            content="""Please call the function with:
{
    "param1": "value1",
    "param2": "value2",
    "nested": {
        "key": "val"
    }
}
Thank you!""",
        )
    ]
    tools = [{"function": {"name": "multi_tool"}}]
    tool_choice = "multi_tool"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = "ok"

        await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: Full multiline JSON extracted
        called_args = mock_exec.call_args[0][1]
        import json

        parsed = json.loads(called_args)
        assert parsed["param1"] == "value1"
        assert parsed["nested"]["key"] == "val"


@pytest.mark.asyncio
async def test_maybe_execute_tools_list_content_extraction():
    """
    Test scenario: User message content as list (containing text and images)
    Expected: Extract JSON from text parts
    """
    from api_utils.utils import maybe_execute_tools

    messages = [
        Message(
            role="user",
            content=cast(
                List[MessageContentItem],
                [
                    {"type": "text", "text": "Before image"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "http://example.com/img.jpg"},
                    },
                    {"type": "text", "text": '{"action": "process_image"}'},
                ],
            ),
        )
    ]
    tools = [{"function": {"name": "image_tool"}}]
    tool_choice = "image_tool"

    with (
        patch(
            "api_utils.utils_ext.tools_execution.execute_tool_call",
            new_callable=AsyncMock,
        ) as mock_exec,
        patch("api_utils.utils_ext.tools_execution.register_runtime_tools"),
    ):
        mock_exec.return_value = "processed"

        await maybe_execute_tools(messages, tools, tool_choice)

        # Verify: JSON extracted from concatenated text
        # _get_latest_user_text concatenates: "Before image\n{\"action\": \"process_image\"}"
        # _extract_json_from_text extracts: {"action": "process_image"}
        mock_exec.assert_called_once_with("image_tool", '{"action": "process_image"}')