feat: add session-scoped client tool waits

This commit is contained in:
Xin Wang
2026-07-31 23:53:47 +08:00
parent f155f98e6e
commit ad5ff061bb
10 changed files with 339 additions and 26 deletions

View File

@@ -3,8 +3,11 @@ import unittest
from models import RuntimeTool
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
InputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
StopFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from services.client_tools import ClientToolBroker, ClientToolError
@@ -51,14 +54,32 @@ class FakeClientTools:
*,
timeout_seconds,
wait_for_response=True,
response_wait_mode="timeout",
):
self.calls.append(
(function_name, arguments, timeout_seconds, wait_for_response)
(
function_name,
arguments,
timeout_seconds,
wait_for_response,
response_wait_mode,
)
)
return self.result
class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
def test_response_wait_mode_defaults_and_uses_camel_case(self):
legacy = ClientToolConfig()
session = ClientToolConfig(responseWaitMode="session")
self.assertEqual(legacy.response_wait_mode, "timeout")
self.assertEqual(session.response_wait_mode, "session")
self.assertEqual(
session.model_dump(by_alias=True)["responseWaitMode"],
"session",
)
def test_fire_and_forget_rejects_result_assignments(self):
with self.assertRaisesRegex(ValueError, "不能配置结果变量赋值"):
ClientToolConfig(
@@ -80,9 +101,27 @@ class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
self.assertIs(store.values["photo_button_visible"], True)
self.assertEqual(
port.calls,
[("set_photo_button_visible", {"visible": True}, 3.0, True)],
[
(
"set_photo_button_visible",
{"visible": True},
3.0,
True,
"timeout",
)
],
)
async def test_session_wait_mode_is_forwarded(self):
tool = client_tool()
tool.definition["config"]["response_wait_mode"] = "session"
port = FakeClientTools({"status": "ok", "data": {"visible": True}})
executor = ToolExecutor(DynamicVariableStore({}), client_tools=port)
await executor.execute(tool, {"visible": True})
self.assertEqual(port.calls[0][-1], "session")
async def test_failure_does_not_update_variable(self):
store = DynamicVariableStore({"photo_button_visible": False})
executor = ToolExecutor(
@@ -152,6 +191,160 @@ class ClientToolBrokerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(broker._pending, {})
self.assertFalse(outbound[0][0].message["wait_for_response"])
async def test_session_wait_is_released_by_end_frame(self):
broker = ClientToolBroker()
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pass
broker.push_frame = push_frame
call = asyncio.create_task(
broker.call(
"show_message",
{},
timeout_seconds=0.001,
response_wait_mode="session",
)
)
await asyncio.sleep(0.01)
self.assertFalse(call.done())
await broker.process_frame(EndFrame(), FrameDirection.DOWNSTREAM)
with self.assertRaisesRegex(ClientToolError, "会话已结束"):
await call
self.assertEqual(broker._pending, {})
async def test_session_wait_is_released_by_cleanup(self):
broker = ClientToolBroker()
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pass
broker.push_frame = push_frame
call = asyncio.create_task(
broker.call(
"show_message",
{},
timeout_seconds=0.001,
response_wait_mode="session",
)
)
await asyncio.sleep(0.01)
self.assertFalse(call.done())
await broker.cleanup()
with self.assertRaisesRegex(ClientToolError, "通道已关闭"):
await call
self.assertEqual(broker._pending, {})
async def test_call_after_end_frame_fails_without_registering(self):
broker = ClientToolBroker()
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append(frame)
broker.push_frame = push_frame
await broker.process_frame(EndFrame(), FrameDirection.DOWNSTREAM)
with self.assertRaisesRegex(ClientToolError, "会话已结束"):
await broker.call(
"show_message",
{},
timeout_seconds=1,
response_wait_mode="session",
)
self.assertEqual(broker._pending, {})
self.assertEqual(len(outbound), 1)
async def test_call_after_cleanup_fails_without_registering(self):
broker = ClientToolBroker()
await broker.cleanup()
with self.assertRaisesRegex(ClientToolError, "通道已关闭"):
await broker.call(
"show_message",
{},
timeout_seconds=1,
response_wait_mode="session",
)
self.assertEqual(broker._pending, {})
async def test_cancel_frame_closes_broker(self):
broker = ClientToolBroker()
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pass
broker.push_frame = push_frame
await broker.process_frame(CancelFrame(), FrameDirection.DOWNSTREAM)
with self.assertRaisesRegex(ClientToolError, "会话已取消"):
await broker.call(
"show_message",
{},
timeout_seconds=1,
response_wait_mode="session",
)
async def test_stop_frame_releases_pending_but_keeps_broker_reusable(self):
broker = ClientToolBroker()
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append(frame)
broker.push_frame = push_frame
stopped_call = asyncio.create_task(
broker.call(
"show_message",
{},
timeout_seconds=1,
response_wait_mode="session",
)
)
await asyncio.sleep(0)
await broker.process_frame(StopFrame(), FrameDirection.DOWNSTREAM)
with self.assertRaisesRegex(ClientToolError, "管线已停止"):
await stopped_call
self.assertEqual(broker._pending, {})
next_call = asyncio.create_task(
broker.call(
"show_message",
{},
timeout_seconds=1,
response_wait_mode="session",
)
)
await asyncio.sleep(0)
message = next(
frame.message
for frame in reversed(outbound)
if isinstance(frame, OutputTransportMessageUrgentFrame)
)
await broker.process_frame(
InputTransportMessageFrame(
message={
"type": "client-tool-result",
"tool_call_id": message["tool_call_id"],
"status": "ok",
"data": {"action": "confirmed"},
}
),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(
await next_call,
{"status": "ok", "data": {"action": "confirmed"}},
)
if __name__ == "__main__":
unittest.main()