Port multi-task unit tests from pipecat-subagents
Brings over 215 tests across 15 files covering the new multi-task framework: BaseTask / PipelineTask bus lifecycle, job RPC and job groups, the bus message hierarchy and serializers, TaskBus + AsyncQueueBus + RedisBus + PgmqBus (with direct and isolated backends), TaskRegistry, the BusBridgeProcessor, the WebSocket proxy tasks, the LLMTask deferral logic, and the PipelineRunner spawn-and-attach flow.
This commit is contained in:
2
.github/workflows/coverage.yaml
vendored
2
.github/workflows/coverage.yaml
vendored
@@ -41,7 +41,9 @@ jobs:
|
||||
--extra google \
|
||||
--extra langchain \
|
||||
--extra livekit \
|
||||
--extra pgmq \
|
||||
--extra piper \
|
||||
--extra redis \
|
||||
--extra runner \
|
||||
--extra sagemaker \
|
||||
--extra tracing \
|
||||
|
||||
2
.github/workflows/tests.yaml
vendored
2
.github/workflows/tests.yaml
vendored
@@ -45,7 +45,9 @@ jobs:
|
||||
--extra google \
|
||||
--extra langchain \
|
||||
--extra livekit \
|
||||
--extra pgmq \
|
||||
--extra piper \
|
||||
--extra redis \
|
||||
--extra runner \
|
||||
--extra sagemaker \
|
||||
--extra tracing \
|
||||
|
||||
1343
tests/test_base_task.py
Normal file
1343
tests/test_base_task.py
Normal file
File diff suppressed because it is too large
Load Diff
267
tests/test_bridge_processor.py
Normal file
267
tests/test_bridge_processor.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import AsyncQueueBus, BusBridgeProcessor, BusFrameMessage
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.tests.utils import run_test
|
||||
|
||||
|
||||
class TestBusBridgeProcessor(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_frames_sent_to_bus_not_passed_through(self):
|
||||
"""Non-lifecycle frames are sent to the bus, not passed through."""
|
||||
bus = AsyncQueueBus()
|
||||
sent_to_bus = []
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(msg):
|
||||
sent_to_bus.append(msg)
|
||||
await original_send(msg)
|
||||
|
||||
bus.send = capture_send
|
||||
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="test_task",
|
||||
)
|
||||
pipeline = Pipeline([processor])
|
||||
|
||||
frames_to_send = [TextFrame(text="hello")]
|
||||
|
||||
down, _ = await run_test(
|
||||
pipeline,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=[],
|
||||
)
|
||||
|
||||
# Frame NOT passed through downstream
|
||||
text_frames = [f for f in down if isinstance(f, TextFrame)]
|
||||
self.assertEqual(len(text_frames), 0)
|
||||
|
||||
# Frame sent to bus
|
||||
bus_frame_msgs = [m for m in sent_to_bus if isinstance(m, BusFrameMessage)]
|
||||
self.assertEqual(len(bus_frame_msgs), 1)
|
||||
self.assertEqual(bus_frame_msgs[0].frame.text, "hello")
|
||||
self.assertEqual(bus_frame_msgs[0].source, "test_task")
|
||||
self.assertEqual(bus_frame_msgs[0].direction, FrameDirection.DOWNSTREAM)
|
||||
|
||||
async def test_lifecycle_frames_pass_through_not_sent_to_bus(self):
|
||||
"""Lifecycle frames pass through but are never sent to the bus."""
|
||||
bus = AsyncQueueBus()
|
||||
sent_to_bus = []
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(msg):
|
||||
sent_to_bus.append(msg)
|
||||
await original_send(msg)
|
||||
|
||||
bus.send = capture_send
|
||||
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="test_task",
|
||||
)
|
||||
pipeline = Pipeline([processor])
|
||||
|
||||
# run_test sends StartFrame + frames_to_send + EndFrame
|
||||
# TextFrame goes to bus (not downstream), lifecycle frames pass through
|
||||
frames_to_send = [TextFrame(text="hello")]
|
||||
await run_test(
|
||||
pipeline,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=[],
|
||||
)
|
||||
|
||||
bus_frame_msgs = [m for m in sent_to_bus if isinstance(m, BusFrameMessage)]
|
||||
# Only the TextFrame, not StartFrame or EndFrame
|
||||
self.assertEqual(len(bus_frame_msgs), 1)
|
||||
self.assertIsInstance(bus_frame_msgs[0].frame, TextFrame)
|
||||
|
||||
async def test_exclude_frames_not_sent_to_bus(self):
|
||||
"""Excluded frame types pass through but are not sent to the bus."""
|
||||
bus = AsyncQueueBus()
|
||||
sent_to_bus = []
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(msg):
|
||||
sent_to_bus.append(msg)
|
||||
await original_send(msg)
|
||||
|
||||
bus.send = capture_send
|
||||
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="test_task",
|
||||
exclude_frames=(TextFrame,),
|
||||
)
|
||||
pipeline = Pipeline([processor])
|
||||
|
||||
frames_to_send = [TextFrame(text="excluded")]
|
||||
expected_down_frames = [TextFrame]
|
||||
|
||||
down, _ = await run_test(
|
||||
pipeline,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
)
|
||||
|
||||
# Frame passed through
|
||||
self.assertEqual(len(down), 1)
|
||||
self.assertEqual(down[0].text, "excluded")
|
||||
|
||||
# But NOT sent to bus
|
||||
bus_frame_msgs = [m for m in sent_to_bus if isinstance(m, BusFrameMessage)]
|
||||
self.assertEqual(len(bus_frame_msgs), 0)
|
||||
|
||||
async def test_bus_frame_injected_at_bridge(self):
|
||||
"""Frames from the bus are injected at the bridge position and
|
||||
travel downstream alongside frames from later pipeline processors."""
|
||||
from pipecat.frames.frames import EndFrame
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
class AppendFrameProcessor(FrameProcessor):
|
||||
"""Appends a TextFrame for every TextFrame it sees."""
|
||||
|
||||
async def process_frame(self, frame, direction):
|
||||
await super().process_frame(frame, direction)
|
||||
await self.push_frame(frame, direction)
|
||||
if isinstance(frame, TextFrame):
|
||||
await self.push_frame(TextFrame(text="after_bridge"), direction)
|
||||
|
||||
bus = AsyncQueueBus()
|
||||
bridge = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="main_task",
|
||||
)
|
||||
pipeline = Pipeline([bridge, AppendFrameProcessor()])
|
||||
task = PipelineTask(pipeline, cancel_on_idle_timeout=False)
|
||||
|
||||
received = []
|
||||
task.set_reached_downstream_filter((TextFrame,))
|
||||
|
||||
@task.event_handler("on_frame_reached_downstream")
|
||||
async def on_frame(task, frame):
|
||||
received.append(frame)
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="child_task",
|
||||
frame=TextFrame(text="from_child"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
|
||||
async def inject_and_end():
|
||||
await asyncio.sleep(0.02)
|
||||
await bridge.on_bus_message(msg)
|
||||
await asyncio.sleep(0.02)
|
||||
await task.queue_frame(EndFrame())
|
||||
|
||||
runner = PipelineRunner()
|
||||
await asyncio.gather(runner.run(task), inject_and_end())
|
||||
|
||||
texts = [f.text for f in received if isinstance(f, TextFrame)]
|
||||
self.assertIn("from_child", texts)
|
||||
self.assertIn("after_bridge", texts)
|
||||
|
||||
async def test_skips_own_frames(self):
|
||||
"""Bridge ignores bus frames from its own task."""
|
||||
bus = AsyncQueueBus()
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="test_task",
|
||||
)
|
||||
|
||||
injected = []
|
||||
original_push = processor.push_frame
|
||||
|
||||
async def capture_push(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
injected.append(frame)
|
||||
await original_push(frame, direction)
|
||||
|
||||
processor.push_frame = capture_push
|
||||
|
||||
# Own frame should be ignored
|
||||
msg = BusFrameMessage(
|
||||
source="test_task",
|
||||
frame=TextFrame(text="self"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await processor.on_bus_message(msg)
|
||||
|
||||
# Should not have injected anything
|
||||
self.assertEqual(len(injected), 0)
|
||||
|
||||
async def test_target_task_filtering(self):
|
||||
"""Bridge with target_task only accepts frames from that task."""
|
||||
bus = AsyncQueueBus()
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="main_task",
|
||||
target_task="specific_child",
|
||||
)
|
||||
|
||||
injected = []
|
||||
original_push = processor.push_frame
|
||||
|
||||
async def capture_push(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
injected.append(frame)
|
||||
await original_push(frame, direction)
|
||||
|
||||
processor.push_frame = capture_push
|
||||
|
||||
# Frame from wrong task — should be ignored
|
||||
wrong_msg = BusFrameMessage(
|
||||
source="other_child",
|
||||
frame=TextFrame(text="wrong"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await processor.on_bus_message(wrong_msg)
|
||||
self.assertEqual(len(injected), 0)
|
||||
|
||||
# Frame from correct task — should be injected
|
||||
right_msg = BusFrameMessage(
|
||||
source="specific_child",
|
||||
frame=TextFrame(text="right"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await processor.on_bus_message(right_msg)
|
||||
self.assertEqual(len(injected), 1)
|
||||
self.assertEqual(injected[0].text, "right")
|
||||
|
||||
async def test_targeted_message_for_other_task_skipped(self):
|
||||
"""Bridge skips bus messages targeted at a different task."""
|
||||
bus = AsyncQueueBus()
|
||||
processor = BusBridgeProcessor(
|
||||
bus=bus,
|
||||
task_name="main_task",
|
||||
)
|
||||
|
||||
injected = []
|
||||
original_push = processor.push_frame
|
||||
|
||||
async def capture_push(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
injected.append(frame)
|
||||
await original_push(frame, direction)
|
||||
|
||||
processor.push_frame = capture_push
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="child",
|
||||
target="other_task",
|
||||
frame=TextFrame(text="not_for_me"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await processor.on_bus_message(msg)
|
||||
self.assertEqual(len(injected), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
274
tests/test_bus.py
Normal file
274
tests/test_bus.py
Normal file
@@ -0,0 +1,274 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import (
|
||||
AsyncQueueBus,
|
||||
BusCancelMessage,
|
||||
BusCancelTaskMessage,
|
||||
BusDataMessage,
|
||||
BusJobCancelMessage,
|
||||
BusSubscriber,
|
||||
)
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
|
||||
async def create_test_bus():
|
||||
"""Create an AsyncQueueBus with a TaskManager for testing."""
|
||||
bus = AsyncQueueBus()
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
return bus, tm
|
||||
|
||||
|
||||
class _CollectorSub(BusSubscriber):
|
||||
"""Subscriber that collects messages into a list."""
|
||||
|
||||
_counter = itertools.count()
|
||||
|
||||
def __init__(self):
|
||||
self._name = f"collector_{next(self._counter)}"
|
||||
self.received = []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
self.received.append(message)
|
||||
|
||||
|
||||
class TestAsyncQueueBus(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_send_delivers_to_subscriber(self):
|
||||
"""send() delivers a message to a subscriber."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
msg = BusDataMessage(source="task_a")
|
||||
await bus.send(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
self.assertIs(sub.received[0], msg)
|
||||
|
||||
async def test_multiple_messages_in_order(self):
|
||||
"""Messages are dispatched in FIFO order."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
msgs = [BusDataMessage(source=f"task_{i}") for i in range(5)]
|
||||
for m in msgs:
|
||||
await bus.send(m)
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(sub.received), 5)
|
||||
for sent, got in zip(msgs, sub.received):
|
||||
self.assertIs(sent, got)
|
||||
|
||||
async def test_start_stop_lifecycle(self):
|
||||
"""start() begins dispatch tasks, stop() cancels them cleanly."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
|
||||
await bus.stop()
|
||||
|
||||
await bus.send(BusDataMessage(source="b"))
|
||||
await asyncio.sleep(0.05)
|
||||
# After stop, messages are not dispatched
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
|
||||
|
||||
class TestBusSubscriber(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_subscribe_calls_on_bus_message(self):
|
||||
"""subscribe() delivers messages to subscriber's on_bus_message."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
msg = BusDataMessage(source="task_a")
|
||||
await bus.send(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
self.assertIs(sub.received[0], msg)
|
||||
|
||||
async def test_multiple_subscribers_independent(self):
|
||||
"""Two subscribers each get every message on their own task."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub1 = _CollectorSub()
|
||||
sub2 = _CollectorSub()
|
||||
await bus.subscribe(sub1)
|
||||
await bus.subscribe(sub2)
|
||||
await bus.start()
|
||||
|
||||
msg = BusDataMessage(source="task_a")
|
||||
await bus.send(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(sub1.received), 1)
|
||||
self.assertEqual(len(sub2.received), 1)
|
||||
self.assertIs(sub1.received[0], msg)
|
||||
self.assertIs(sub2.received[0], msg)
|
||||
|
||||
async def test_unsubscribe_stops_delivery(self):
|
||||
"""unsubscribe() prevents further message delivery."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
|
||||
await bus.unsubscribe(sub)
|
||||
await bus.send(BusDataMessage(source="b"))
|
||||
await asyncio.sleep(0.05)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(sub.received), 1)
|
||||
|
||||
async def test_slow_subscriber_does_not_block_others(self):
|
||||
"""A slow subscriber does not block a fast subscriber."""
|
||||
bus, _ = await create_test_bus()
|
||||
fast_received = []
|
||||
fast_done = asyncio.Event()
|
||||
|
||||
class SlowSub(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "slow"
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
class FastSub(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fast"
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
fast_received.append(message)
|
||||
fast_done.set()
|
||||
|
||||
await bus.subscribe(SlowSub())
|
||||
await bus.subscribe(FastSub())
|
||||
await bus.start()
|
||||
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
await asyncio.wait_for(fast_done.wait(), timeout=0.1)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(len(fast_received), 1)
|
||||
|
||||
|
||||
class TestBusMessagePriority(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_system_message_preempts_data_messages(self):
|
||||
"""System messages are delivered before data messages queued earlier."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
|
||||
# Queue messages before starting dispatch
|
||||
for i in range(5):
|
||||
await bus.send(BusDataMessage(source=f"data_{i}"))
|
||||
await bus.send(BusCancelMessage(source="runner", reason="urgent"))
|
||||
|
||||
await bus.start()
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
# System message should be first
|
||||
self.assertIsInstance(sub.received[0], BusCancelMessage)
|
||||
self.assertEqual(sub.received[0].source, "runner")
|
||||
|
||||
async def test_data_messages_preserve_fifo_order(self):
|
||||
"""Data messages maintain FIFO order among themselves."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
for i in range(5):
|
||||
await bus.send(BusDataMessage(source=f"task_{i}"))
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
sources = [m.source for m in sub.received]
|
||||
self.assertEqual(sources, [f"task_{i}" for i in range(5)])
|
||||
|
||||
async def test_system_messages_preserve_fifo_order(self):
|
||||
"""Multiple system messages maintain FIFO order among themselves."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
await bus.start()
|
||||
|
||||
await bus.send(BusCancelMessage(source="first", reason="a"))
|
||||
await bus.send(BusCancelMessage(source="second", reason="b"))
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
self.assertEqual(sub.received[0].source, "first")
|
||||
self.assertEqual(sub.received[1].source, "second")
|
||||
|
||||
async def test_mixed_messages_system_first(self):
|
||||
"""When data and system messages are queued, all system come first."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
|
||||
# Queue before starting so all messages are pending
|
||||
await bus.send(BusDataMessage(source="data_1"))
|
||||
await bus.send(BusDataMessage(source="data_2"))
|
||||
await bus.send(BusCancelMessage(source="cancel_1"))
|
||||
await bus.send(BusDataMessage(source="data_3"))
|
||||
await bus.send(BusCancelTaskMessage(source="cancel_2", target="task"))
|
||||
|
||||
await bus.start()
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
sources = [m.source for m in sub.received]
|
||||
self.assertEqual(sources, ["cancel_1", "cancel_2", "data_1", "data_2", "data_3"])
|
||||
|
||||
async def test_task_cancel_is_system_priority(self):
|
||||
"""BusJobCancelMessage has system priority."""
|
||||
bus, _ = await create_test_bus()
|
||||
sub = _CollectorSub()
|
||||
await bus.subscribe(sub)
|
||||
|
||||
await bus.send(BusDataMessage(source="data"))
|
||||
await bus.send(BusJobCancelMessage(source="parent", target="worker", job_id="t1"))
|
||||
|
||||
await bus.start()
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
self.assertIsInstance(sub.received[0], BusJobCancelMessage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
35
tests/test_bus_network.py
Normal file
35
tests/test_bus_network.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Tests for ``pipecat.bus.network`` lazy imports."""
|
||||
|
||||
import unittest
|
||||
|
||||
import pipecat.bus.network as network
|
||||
|
||||
|
||||
class TestNetworkPackageLazyImports(unittest.TestCase):
|
||||
def test_pgmq_bus_lazy_import(self):
|
||||
"""``PgmqBus`` is resolved on first attribute access."""
|
||||
from pipecat.bus.network.pgmq import PgmqBus as Direct
|
||||
|
||||
self.assertIs(network.PgmqBus, Direct)
|
||||
|
||||
def test_redis_bus_lazy_import(self):
|
||||
"""``RedisBus`` is resolved on first attribute access."""
|
||||
from pipecat.bus.network.redis import RedisBus as Direct
|
||||
|
||||
self.assertIs(network.RedisBus, Direct)
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
"""Unknown attributes raise a clear ``AttributeError``."""
|
||||
with self.assertRaises(AttributeError) as ctx:
|
||||
network.NotARealBus # noqa: B018
|
||||
self.assertIn("NotARealBus", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
163
tests/test_frame_adapters.py
Normal file
163
tests/test_frame_adapters.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
from pipecat.bus.messages import BusFrameMessage
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.frames.frames import LLMContextFrame, TextFrame, TranscriptionFrame
|
||||
from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContext,
|
||||
LLMSpecificMessage,
|
||||
NotGiven,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
|
||||
|
||||
class TestTextFrameRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
def test_round_trip(self):
|
||||
msg = BusFrameMessage(
|
||||
source="a",
|
||||
frame=TextFrame(text="hello world"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored.frame, TextFrame)
|
||||
self.assertEqual(restored.frame.text, "hello world")
|
||||
|
||||
|
||||
class TestTranscriptionFrameRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
def test_round_trip_basic(self):
|
||||
msg = BusFrameMessage(
|
||||
source="a",
|
||||
frame=TranscriptionFrame(
|
||||
text="hello",
|
||||
user_id="user-1",
|
||||
timestamp="2026-03-17T00:00:00Z",
|
||||
),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored.frame, TranscriptionFrame)
|
||||
self.assertEqual(restored.frame.text, "hello")
|
||||
self.assertEqual(restored.frame.user_id, "user-1")
|
||||
self.assertEqual(restored.frame.timestamp, "2026-03-17T00:00:00Z")
|
||||
self.assertIsNone(restored.frame.language)
|
||||
self.assertFalse(restored.frame.finalized)
|
||||
|
||||
def test_round_trip_with_language(self):
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="a",
|
||||
frame=TranscriptionFrame(
|
||||
text="hola",
|
||||
user_id="user-1",
|
||||
timestamp="2026-03-17T00:00:00Z",
|
||||
language=Language.ES,
|
||||
finalized=True,
|
||||
),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertEqual(restored.frame.language, Language.ES)
|
||||
self.assertTrue(restored.frame.finalized)
|
||||
|
||||
|
||||
class TestLLMContextFrameRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
def _round_trip_context(self, ctx):
|
||||
msg = BusFrameMessage(
|
||||
source="a",
|
||||
frame=LLMContextFrame(context=ctx),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
self.assertIsInstance(restored.frame, LLMContextFrame)
|
||||
return restored.frame.context
|
||||
|
||||
def test_round_trip_messages_only(self):
|
||||
ctx = LLMContext(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
)
|
||||
restored = self._round_trip_context(ctx)
|
||||
|
||||
self.assertEqual(len(restored.messages), 2)
|
||||
self.assertEqual(restored.messages[0]["role"], "system")
|
||||
self.assertEqual(restored.messages[1]["content"], "hello")
|
||||
self.assertIsInstance(restored.tools, NotGiven)
|
||||
self.assertIsInstance(restored.tool_choice, NotGiven)
|
||||
|
||||
def test_round_trip_with_specific_message(self):
|
||||
ctx = LLMContext(
|
||||
messages=[
|
||||
{"role": "user", "content": "hi"},
|
||||
LLMSpecificMessage(llm="anthropic", message={"custom": "data"}),
|
||||
]
|
||||
)
|
||||
restored = self._round_trip_context(ctx)
|
||||
|
||||
self.assertEqual(len(restored.messages), 2)
|
||||
self.assertIsInstance(restored.messages[0], dict)
|
||||
self.assertIsInstance(restored.messages[1], LLMSpecificMessage)
|
||||
self.assertEqual(restored.messages[1].llm, "anthropic")
|
||||
self.assertEqual(restored.messages[1].message, {"custom": "data"})
|
||||
|
||||
def test_round_trip_with_tools(self):
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
|
||||
tools = ToolsSchema(
|
||||
standard_tools=[
|
||||
FunctionSchema(
|
||||
name="get_weather",
|
||||
description="Get the weather",
|
||||
properties={"location": {"type": "string"}},
|
||||
required=["location"],
|
||||
),
|
||||
]
|
||||
)
|
||||
ctx = LLMContext(
|
||||
messages=[{"role": "user", "content": "weather?"}],
|
||||
tools=tools,
|
||||
)
|
||||
restored = self._round_trip_context(ctx)
|
||||
|
||||
self.assertNotIsInstance(restored.tools, NotGiven)
|
||||
self.assertEqual(len(restored.tools.standard_tools), 1)
|
||||
self.assertEqual(restored.tools.standard_tools[0].name, "get_weather")
|
||||
self.assertEqual(restored.tools.standard_tools[0].required, ["location"])
|
||||
|
||||
def test_round_trip_with_tool_choice(self):
|
||||
ctx = LLMContext(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tool_choice="auto",
|
||||
)
|
||||
restored = self._round_trip_context(ctx)
|
||||
|
||||
self.assertEqual(restored.tool_choice, "auto")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
739
tests/test_job_group.py
Normal file
739
tests/test_job_group.py
Normal file
@@ -0,0 +1,739 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import (
|
||||
AsyncQueueBus,
|
||||
BusJobCancelMessage,
|
||||
BusJobRequestMessage,
|
||||
BusJobResponseMessage,
|
||||
)
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.pipeline.job_context import (
|
||||
JobError,
|
||||
JobEvent,
|
||||
JobGroupError,
|
||||
JobGroupEvent,
|
||||
JobStatus,
|
||||
)
|
||||
from pipecat.registry import TaskRegistry
|
||||
from pipecat.registry.types import TaskReadyData
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
|
||||
class StubTask(BaseTask):
|
||||
pass
|
||||
|
||||
|
||||
class JobWorkerTask(BaseTask):
|
||||
"""Worker that automatically responds to job requests via the bus."""
|
||||
|
||||
def __init__(self, name, *, response=None, status=JobStatus.COMPLETED):
|
||||
super().__init__(name)
|
||||
self._auto_response = response
|
||||
self._auto_status = status
|
||||
|
||||
async def on_job_request(self, message):
|
||||
await super().on_job_request(message)
|
||||
await self.send_job_response(message.job_id, self._auto_response, status=self._auto_status)
|
||||
|
||||
|
||||
class UrgentJobWorkerTask(BaseTask):
|
||||
"""Worker that responds urgently to job requests."""
|
||||
|
||||
def __init__(self, name, *, response=None, status=JobStatus.COMPLETED):
|
||||
super().__init__(name)
|
||||
self._auto_response = response
|
||||
self._auto_status = status
|
||||
|
||||
async def on_job_request(self, message):
|
||||
await super().on_job_request(message)
|
||||
await self.send_job_response(
|
||||
message.job_id, self._auto_response, status=self._auto_status, urgent=True
|
||||
)
|
||||
|
||||
|
||||
class UpdatingWorkerTask(BaseTask):
|
||||
"""Worker that sends updates before responding."""
|
||||
|
||||
def __init__(self, name, *, updates, response=None):
|
||||
super().__init__(name)
|
||||
self._updates = updates
|
||||
self._auto_response = response
|
||||
|
||||
async def on_job_request(self, message):
|
||||
await super().on_job_request(message)
|
||||
for update in self._updates:
|
||||
await self.send_job_update(message.job_id, update)
|
||||
await self.send_job_response(message.job_id, self._auto_response)
|
||||
|
||||
|
||||
class StreamingWorkerTask(BaseTask):
|
||||
"""Worker that streams data before responding."""
|
||||
|
||||
def __init__(self, name, *, chunks, response=None):
|
||||
super().__init__(name)
|
||||
self._chunks = chunks
|
||||
self._auto_response = response
|
||||
|
||||
async def on_job_request(self, message):
|
||||
await super().on_job_request(message)
|
||||
await self.send_job_stream_start(message.job_id, {"content_type": "text"})
|
||||
for chunk in self._chunks:
|
||||
await self.send_job_stream_data(message.job_id, chunk)
|
||||
await self.send_job_stream_end(message.job_id, self._auto_response)
|
||||
|
||||
|
||||
class SlowWorkerTask(BaseTask):
|
||||
"""Worker that blocks during job execution until cancelled."""
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.started = asyncio.Event()
|
||||
self.was_cancelled = False
|
||||
|
||||
async def on_job_request(self, message):
|
||||
await super().on_job_request(message)
|
||||
self.started.set()
|
||||
try:
|
||||
# Block until cancelled
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
self.was_cancelled = True
|
||||
|
||||
|
||||
async def create_test_env():
|
||||
bus = AsyncQueueBus()
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
registry = TaskRegistry(runner_name="test-runner")
|
||||
return bus, tm, registry
|
||||
|
||||
|
||||
async def setup_task(bus, registry, task):
|
||||
"""Subscribe a task to the bus and register it as ready."""
|
||||
task.attach(registry=registry, bus=bus)
|
||||
await task.setup(bus.task_manager)
|
||||
await bus.subscribe(task)
|
||||
await registry.register(TaskReadyData(task_name=task.name, runner="test-runner"))
|
||||
|
||||
|
||||
def capture_bus(bus):
|
||||
sent = []
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(message):
|
||||
sent.append(message)
|
||||
await original_send(message)
|
||||
|
||||
bus.send = capture_send
|
||||
return sent
|
||||
|
||||
|
||||
class TestJobGroupContext(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.tm, self.registry = await create_test_env()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.bus.stop()
|
||||
|
||||
async def test_job_group_collects_responses(self):
|
||||
"""job_group() context manager collects all responses."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
w1 = JobWorkerTask("w1", response={"a": 1})
|
||||
w2 = JobWorkerTask("w2", response={"b": 2})
|
||||
await setup_task(self.bus, self.registry, w1)
|
||||
await setup_task(self.bus, self.registry, w2)
|
||||
|
||||
async with parent.job_group("w1", "w2", payload={"work": True}) as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(tg.responses, {"w1": {"a": 1}, "w2": {"b": 2}})
|
||||
|
||||
async def test_job_group_sends_request(self):
|
||||
"""job_group() sends BusJobRequestMessage to each task."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
w1 = JobWorkerTask("w1", response={"ok": True})
|
||||
await setup_task(self.bus, self.registry, w1)
|
||||
|
||||
async with parent.job_group("w1", payload={"data": 1}) as tg:
|
||||
pass
|
||||
|
||||
request_msgs = [m for m in sent if isinstance(m, BusJobRequestMessage)]
|
||||
self.assertEqual(len(request_msgs), 1)
|
||||
self.assertEqual(request_msgs[0].payload, {"data": 1})
|
||||
|
||||
async def test_job_group_raises_on_cancel(self):
|
||||
"""job_group() raises JobGroupError when group is cancelled."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
# StubTask doesn't auto-respond, so we can cancel manually
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(JobGroupError) as ctx:
|
||||
async with parent.job_group("worker") as tg:
|
||||
asyncio.ensure_future(parent.cancel_job_group(tg.job_id, reason="manual cancel"))
|
||||
|
||||
# Let the event loop schedule handler tasks spawned by the bus
|
||||
await asyncio.sleep(0)
|
||||
self.assertIn("manual cancel", str(ctx.exception))
|
||||
|
||||
async def test_job_group_raises_on_timeout(self):
|
||||
"""job_group() raises JobGroupError on timeout."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
# StubTask doesn't auto-respond, so timeout fires
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(JobGroupError) as ctx:
|
||||
async with parent.job_group("worker", timeout=0.05) as tg:
|
||||
pass
|
||||
|
||||
self.assertIn("timeout", str(ctx.exception))
|
||||
|
||||
async def test_job_group_raises_on_ready_timeout(self):
|
||||
"""job_group() raises JobGroupError when tasks aren't ready in time."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
# "ghost" is never registered, so the ready-wait times out
|
||||
with self.assertRaises(JobGroupError) as ctx:
|
||||
async with parent.job_group("ghost", timeout=0.05) as tg:
|
||||
pass
|
||||
|
||||
self.assertIn("not ready", str(ctx.exception))
|
||||
|
||||
async def test_job_group_cancels_on_block_exception(self):
|
||||
"""job_group() cancels remaining jobs when the block raises."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
async with parent.job_group("worker") as tg:
|
||||
raise ValueError("something went wrong")
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 1)
|
||||
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
|
||||
|
||||
async def test_job_group_raises_on_worker_error(self):
|
||||
"""job_group() raises JobGroupError when a worker errors with cancel_on_error."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"error": "failed"}, status=JobStatus.ERROR)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(JobGroupError):
|
||||
async with parent.job_group("worker") as tg:
|
||||
pass
|
||||
|
||||
# Error response is tracked in partial responses
|
||||
self.assertEqual(tg.responses, {"worker": {"error": "failed"}})
|
||||
|
||||
async def test_job_group_on_job_error_fires(self):
|
||||
"""on_job_error fires when a worker errors with cancel_on_error."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"error": "boom"}, status=JobStatus.ERROR)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
errors = []
|
||||
|
||||
@parent.event_handler("on_job_error")
|
||||
async def on_error(task, message):
|
||||
errors.append(message)
|
||||
|
||||
with self.assertRaises(JobGroupError):
|
||||
async with parent.job_group("worker") as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertEqual(errors[0].source, "worker")
|
||||
self.assertEqual(errors[0].response, {"error": "boom"})
|
||||
self.assertEqual(errors[0].status, JobStatus.ERROR)
|
||||
|
||||
async def test_job_group_partial_responses_on_error(self):
|
||||
"""Partial responses from successful workers are available after error."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
# w1 responds successfully, w2 responds with error.
|
||||
# Order depends on bus dispatch, but both are registered.
|
||||
w1 = JobWorkerTask("w1", response={"ok": True})
|
||||
w2 = JobWorkerTask("w2", response={"error": "fail"}, status=JobStatus.ERROR)
|
||||
await setup_task(self.bus, self.registry, w1)
|
||||
await setup_task(self.bus, self.registry, w2)
|
||||
|
||||
with self.assertRaises(JobGroupError):
|
||||
async with parent.job_group("w1", "w2") as tg:
|
||||
pass
|
||||
|
||||
# w2's error response should be in partial responses
|
||||
self.assertIn("w2", tg.responses)
|
||||
self.assertEqual(tg.responses["w2"], {"error": "fail"})
|
||||
|
||||
async def test_job_group_job_id_available(self):
|
||||
"""job_id is available inside the async with block."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
captured_job_id = None
|
||||
|
||||
async with parent.job_group("worker") as tg:
|
||||
captured_job_id = tg.job_id
|
||||
|
||||
self.assertIsNotNone(captured_job_id)
|
||||
self.assertEqual(captured_job_id, tg.job_id)
|
||||
|
||||
async def test_job_group_on_job_completed_still_fires(self):
|
||||
"""on_job_completed callback still fires when using job_group()."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("w1", response={"ok": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
completed = []
|
||||
|
||||
@parent.event_handler("on_job_completed")
|
||||
async def on_completed(task, result):
|
||||
completed.append(result)
|
||||
|
||||
async with parent.job_group("w1") as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(completed), 1)
|
||||
self.assertEqual(completed[0].responses, {"w1": {"ok": True}})
|
||||
|
||||
async def test_job_group_iterates_updates(self):
|
||||
"""async for yields update events from workers."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = UpdatingWorkerTask(
|
||||
"worker",
|
||||
updates=[{"progress": 25}, {"progress": 75}],
|
||||
response={"result": "done"},
|
||||
)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
events = []
|
||||
async with parent.job_group("worker", payload={"work": True}) as tg:
|
||||
async for event in tg:
|
||||
events.append(event)
|
||||
|
||||
self.assertEqual(len(events), 2)
|
||||
self.assertEqual(events[0].type, JobGroupEvent.UPDATE)
|
||||
self.assertEqual(events[0].task_name, "worker")
|
||||
self.assertEqual(events[0].data, {"progress": 25})
|
||||
self.assertEqual(events[1].data, {"progress": 75})
|
||||
self.assertEqual(tg.responses, {"worker": {"result": "done"}})
|
||||
|
||||
async def test_job_group_iterates_stream_events(self):
|
||||
"""async for yields stream_start, stream_data, and stream_end events."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StreamingWorkerTask(
|
||||
"worker",
|
||||
chunks=[{"text": "hello "}, {"text": "world"}],
|
||||
response={"final": True},
|
||||
)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
events = []
|
||||
async with parent.job_group("worker") as tg:
|
||||
async for event in tg:
|
||||
events.append(event)
|
||||
|
||||
types = [e.type for e in events]
|
||||
self.assertEqual(
|
||||
types,
|
||||
[
|
||||
JobGroupEvent.STREAM_START,
|
||||
JobGroupEvent.STREAM_DATA,
|
||||
JobGroupEvent.STREAM_DATA,
|
||||
JobGroupEvent.STREAM_END,
|
||||
],
|
||||
)
|
||||
self.assertEqual(events[0].data, {"content_type": "text"})
|
||||
self.assertEqual(events[1].data, {"text": "hello "})
|
||||
self.assertEqual(events[2].data, {"text": "world"})
|
||||
self.assertEqual(events[3].data, {"final": True})
|
||||
|
||||
async def test_job_group_iterates_mixed_events(self):
|
||||
"""async for yields events from multiple workers interleaved."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
w1 = UpdatingWorkerTask("w1", updates=[{"status": "working"}], response={"a": 1})
|
||||
w2 = JobWorkerTask("w2", response={"b": 2})
|
||||
await setup_task(self.bus, self.registry, w1)
|
||||
await setup_task(self.bus, self.registry, w2)
|
||||
|
||||
events = []
|
||||
async with parent.job_group("w1", "w2") as tg:
|
||||
async for event in tg:
|
||||
events.append(event)
|
||||
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0].type, JobGroupEvent.UPDATE)
|
||||
self.assertEqual(events[0].task_name, "w1")
|
||||
self.assertEqual(tg.responses, {"w1": {"a": 1}, "w2": {"b": 2}})
|
||||
|
||||
async def test_job_group_no_iteration_still_works(self):
|
||||
"""job_group() works without iterating (pass body)."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = UpdatingWorkerTask("worker", updates=[{"progress": 50}], response={"ok": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async with parent.job_group("worker") as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(tg.responses, {"worker": {"ok": True}})
|
||||
|
||||
async def test_urgent_job_response_collected(self):
|
||||
"""Urgent job responses are collected like normal responses."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = UrgentJobWorkerTask("worker", response={"urgent": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async with parent.job_group("worker") as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(tg.responses, {"worker": {"urgent": True}})
|
||||
|
||||
async def test_urgent_job_response_triggers_on_job_error(self):
|
||||
"""Urgent error response triggers on_job_error and cancels group."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = UrgentJobWorkerTask(
|
||||
"worker", response={"error": "critical"}, status=JobStatus.ERROR
|
||||
)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
errors = []
|
||||
|
||||
@parent.event_handler("on_job_error")
|
||||
async def on_error(task, message):
|
||||
errors.append(message)
|
||||
|
||||
with self.assertRaises(JobGroupError):
|
||||
async with parent.job_group("worker") as tg:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertEqual(errors[0].response, {"error": "critical"})
|
||||
|
||||
async def test_urgent_response_has_system_priority(self):
|
||||
"""Urgent job response is delivered before normal data messages."""
|
||||
from pipecat.bus import BusDataMessage, BusJobResponseUrgentMessage
|
||||
|
||||
bus = self.bus
|
||||
|
||||
# Queue data messages first, then an urgent response
|
||||
parent = StubTask("parent")
|
||||
await setup_task(bus, self.registry, parent)
|
||||
|
||||
received = []
|
||||
|
||||
@parent.event_handler("on_bus_message")
|
||||
async def on_msg(task, message):
|
||||
received.append(message)
|
||||
|
||||
# Send data messages before starting dispatch
|
||||
for i in range(3):
|
||||
await bus.send(BusDataMessage(source=f"data_{i}"))
|
||||
await bus.send(
|
||||
BusJobResponseUrgentMessage(
|
||||
source="worker", target="parent", job_id="t1", status=JobStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
|
||||
# Start dispatch — urgent should arrive first
|
||||
# bus is already started in setUp; restart by stopping and starting
|
||||
await bus.stop()
|
||||
await bus.start()
|
||||
await asyncio.sleep(0.1)
|
||||
await bus.stop()
|
||||
|
||||
# First message should be the urgent response
|
||||
self.assertIsInstance(received[0], BusJobResponseUrgentMessage)
|
||||
|
||||
|
||||
class TestJobContext(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.tm, self.registry = await create_test_env()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.bus.stop()
|
||||
|
||||
async def test_job_collects_response(self):
|
||||
"""job() context manager collects the worker's response."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"result": 42})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async with parent.job("worker", payload={"x": 1}) as t:
|
||||
pass
|
||||
|
||||
self.assertEqual(t.response, {"result": 42})
|
||||
|
||||
async def test_job_sends_request(self):
|
||||
"""job() sends a BusJobRequestMessage to the task."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"ok": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async with parent.job("worker") as t:
|
||||
pass
|
||||
|
||||
request_msgs = [m for m in sent if isinstance(m, BusJobRequestMessage)]
|
||||
self.assertEqual(len(request_msgs), 1)
|
||||
self.assertEqual(request_msgs[0].target, "worker")
|
||||
self.assertEqual(request_msgs[0].source, "parent")
|
||||
|
||||
async def test_job_iterates_events(self):
|
||||
"""job() yields intermediate events via async for."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = UpdatingWorkerTask("worker", updates=[{"progress": 50}], response={"done": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
events = []
|
||||
async with parent.job("worker") as t:
|
||||
async for event in t:
|
||||
events.append(event)
|
||||
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0].type, JobEvent.UPDATE)
|
||||
self.assertIsInstance(events[0], JobEvent)
|
||||
self.assertEqual(t.response, {"done": True})
|
||||
|
||||
async def test_job_streams_data(self):
|
||||
"""job() yields stream events from a streaming worker."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StreamingWorkerTask(
|
||||
"worker",
|
||||
chunks=[{"text": "hello"}, {"text": "world"}],
|
||||
response={"ok": True},
|
||||
)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
events = []
|
||||
async with parent.job("worker") as t:
|
||||
async for event in t:
|
||||
events.append(event)
|
||||
|
||||
types = [e.type for e in events]
|
||||
self.assertEqual(
|
||||
types,
|
||||
[
|
||||
JobEvent.STREAM_START,
|
||||
JobEvent.STREAM_DATA,
|
||||
JobEvent.STREAM_DATA,
|
||||
JobEvent.STREAM_END,
|
||||
],
|
||||
)
|
||||
self.assertEqual(t.response, {"ok": True})
|
||||
|
||||
async def test_job_cancels_on_exception(self):
|
||||
"""job() cancels the job if the block raises."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
async with parent.job("worker") as t:
|
||||
raise ValueError("something went wrong")
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 1)
|
||||
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
|
||||
|
||||
async def test_job_raises_on_worker_error(self):
|
||||
"""job() raises JobError when the worker errors."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"error": "boom"}, status=JobStatus.ERROR)
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
with self.assertRaises(JobError):
|
||||
async with parent.job("worker") as t:
|
||||
pass
|
||||
|
||||
async def test_job_exposes_job_id(self):
|
||||
"""job() exposes the job_id inside the context."""
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = JobWorkerTask("worker", response={"ok": True})
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async with parent.job("worker") as t:
|
||||
self.assertIsInstance(t.job_id, str)
|
||||
self.assertTrue(len(t.job_id) > 0)
|
||||
|
||||
async def test_job_group_cancels_on_cancelled_error(self):
|
||||
"""job_group() cancels workers when CancelledError is raised (e.g. tool interruption)."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async def run_job_group():
|
||||
async with parent.job_group("worker") as tg:
|
||||
# Simulate tool cancellation while waiting
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
task = asyncio.create_task(run_job_group())
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 1)
|
||||
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
|
||||
# Job group should be cleaned up
|
||||
self.assertEqual(len(parent.job_groups), 0)
|
||||
|
||||
async def test_job_cancels_on_cancelled_error(self):
|
||||
"""job() cancels the worker when CancelledError is raised (e.g. tool interruption)."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = StubTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
async def run_job():
|
||||
async with parent.job("worker") as t:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
task = asyncio.create_task(run_job())
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 1)
|
||||
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
|
||||
self.assertEqual(len(parent.job_groups), 0)
|
||||
|
||||
async def test_fire_and_forget_jobs_cancelled_manually(self):
|
||||
"""User cancels fire-and-forget jobs in a CancelledError handler."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
w1 = StubTask("w1")
|
||||
w2 = StubTask("w2")
|
||||
await setup_task(self.bus, self.registry, w1)
|
||||
await setup_task(self.bus, self.registry, w2)
|
||||
|
||||
# Simulate what a user would do in on_function_calls_cancelled:
|
||||
# track job IDs and cancel only the ones started here
|
||||
try:
|
||||
job_ids = []
|
||||
job_ids.append(await parent.request_job("w1", payload={"job": 1}))
|
||||
job_ids.append(await parent.request_job("w2", payload={"job": 2}))
|
||||
self.assertEqual(len(parent.job_groups), 2)
|
||||
raise asyncio.CancelledError()
|
||||
except asyncio.CancelledError:
|
||||
for jid in job_ids:
|
||||
await parent.cancel_job_group(jid, reason="tool cancelled")
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 2)
|
||||
cancelled_targets = {m.target for m in cancel_msgs}
|
||||
self.assertEqual(cancelled_targets, {"w1", "w2"})
|
||||
for m in cancel_msgs:
|
||||
self.assertEqual(m.reason, "tool cancelled")
|
||||
self.assertEqual(len(parent.job_groups), 0)
|
||||
|
||||
async def test_cancel_interrupts_running_handler(self):
|
||||
"""Cancelling a job interrupts a handler that is currently executing."""
|
||||
sent = capture_bus(self.bus)
|
||||
|
||||
parent = StubTask("parent")
|
||||
await setup_task(self.bus, self.registry, parent)
|
||||
|
||||
worker = SlowWorkerTask("worker")
|
||||
await setup_task(self.bus, self.registry, worker)
|
||||
|
||||
job_id = await parent.request_job("worker", payload={"job": 1})
|
||||
|
||||
# Wait for the worker to start executing
|
||||
await asyncio.wait_for(worker.started.wait(), timeout=2.0)
|
||||
|
||||
# Cancel while the handler is blocked
|
||||
await parent.cancel_job_group(job_id, reason="no longer needed")
|
||||
|
||||
# Give the event loop time to process the cancellation
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# The handler should have been cancelled
|
||||
self.assertTrue(worker.was_cancelled)
|
||||
|
||||
# A cancel message should have been sent
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
|
||||
self.assertEqual(len(cancel_msgs), 1)
|
||||
self.assertEqual(cancel_msgs[0].reason, "no longer needed")
|
||||
|
||||
# A CANCELLED response should have been sent back
|
||||
response_msgs = [m for m in sent if isinstance(m, BusJobResponseMessage)]
|
||||
self.assertEqual(len(response_msgs), 1)
|
||||
self.assertEqual(response_msgs[0].status, JobStatus.CANCELLED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
285
tests/test_llm_task.py
Normal file
285
tests/test_llm_task.py
Normal file
@@ -0,0 +1,285 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pipecat.frames.frames import LLMMessagesAppendFrame
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.tasks.llm import LLMTask, tool
|
||||
from pipecat.tasks.llm.llm_task import PipelineFlushFrame
|
||||
|
||||
|
||||
def _create_task():
|
||||
"""Create a StubLLMTask with mocked parent queue_frame for testing."""
|
||||
|
||||
class StubLLMTask(LLMTask):
|
||||
@tool
|
||||
async def fast_tool(self, params):
|
||||
"""A quick tool."""
|
||||
await params.result_callback("done")
|
||||
|
||||
@tool
|
||||
async def slow_tool(self, params, delay: float):
|
||||
"""A tool that blocks on an event for coordination."""
|
||||
await params.result_callback("done")
|
||||
|
||||
llm = MagicMock()
|
||||
llm.register_direct_function = MagicMock()
|
||||
task = StubLLMTask("test_task", llm=llm, bridged=())
|
||||
|
||||
# Capture frames passed to PipelineTask.queue_frame (i.e. super().queue_frame).
|
||||
# Auto-set _flush_done when PipelineFlushFrame is queued, simulating the flush
|
||||
# round-trip through the pipeline.
|
||||
delivered: list[tuple] = []
|
||||
original_pt_queue_frame = PipelineTask.queue_frame
|
||||
|
||||
async def class_replacement(self, frame, direction=FrameDirection.DOWNSTREAM):
|
||||
# Only intercept for this specific instance; otherwise fall through.
|
||||
if self is task:
|
||||
delivered.append((frame, direction))
|
||||
if isinstance(frame, PipelineFlushFrame):
|
||||
task._flush_done.set()
|
||||
return
|
||||
await original_pt_queue_frame(self, frame, direction)
|
||||
|
||||
PipelineTask.queue_frame = class_replacement
|
||||
task._restore_pt_queue_frame = lambda: setattr(
|
||||
PipelineTask, "queue_frame", original_pt_queue_frame
|
||||
)
|
||||
|
||||
task._delivered_frames = delivered
|
||||
return task
|
||||
|
||||
|
||||
def _get_delivered_frames(task):
|
||||
"""Extract non-flush frames delivered to the underlying pipeline."""
|
||||
return [
|
||||
(frame, direction)
|
||||
for frame, direction in task._delivered_frames
|
||||
if not isinstance(frame, PipelineFlushFrame)
|
||||
]
|
||||
|
||||
|
||||
def _make_frame(content: str, run_llm: bool = True) -> LLMMessagesAppendFrame:
|
||||
return LLMMessagesAppendFrame(messages=[{"role": "user", "content": content}], run_llm=run_llm)
|
||||
|
||||
|
||||
class TestToolCallTracking(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self._tasks = []
|
||||
|
||||
def _track(self, task):
|
||||
self._tasks.append(task)
|
||||
return task
|
||||
|
||||
def tearDown(self):
|
||||
for task in self._tasks:
|
||||
restore = getattr(task, "_restore_pt_queue_frame", None)
|
||||
if restore:
|
||||
restore()
|
||||
|
||||
async def test_tool_call_active_initially_false(self):
|
||||
task = self._track(_create_task())
|
||||
self.assertFalse(task.tool_call_active)
|
||||
|
||||
async def test_tool_call_active_during_execution(self):
|
||||
"""tool_call_active is True while a tool is running."""
|
||||
task = self._track(_create_task())
|
||||
observed = []
|
||||
|
||||
@tool
|
||||
async def gated_tool(self, params):
|
||||
"""Waits on gate."""
|
||||
observed.append(task.tool_call_active)
|
||||
|
||||
wrapped = task._track_tool_call(gated_tool.__get__(task))
|
||||
params = MagicMock()
|
||||
await wrapped(params)
|
||||
|
||||
self.assertTrue(observed[0])
|
||||
self.assertFalse(task.tool_call_active)
|
||||
|
||||
async def test_queue_frame_delivers_immediately_when_idle(self):
|
||||
"""queue_frame delivers immediately when no tools are in-flight."""
|
||||
task = self._track(_create_task())
|
||||
frame = _make_frame("hello")
|
||||
|
||||
await task.queue_frame(frame)
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertIs(delivered[0][0], frame)
|
||||
|
||||
async def test_queue_frame_defers_when_tool_active(self):
|
||||
"""queue_frame defers delivery when a tool is in-flight."""
|
||||
task = self._track(_create_task())
|
||||
task._tool_call_inflight = 1
|
||||
|
||||
frame = _make_frame("deferred")
|
||||
await task.queue_frame(frame)
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 0)
|
||||
self.assertEqual(len(task._deferred_frames), 1)
|
||||
self.assertEqual(task._deferred_frames[0], (frame, FrameDirection.DOWNSTREAM))
|
||||
|
||||
async def test_deferred_frames_flush_when_tool_completes(self):
|
||||
"""Frames deferred during a tool call are delivered when it finishes."""
|
||||
task = self._track(_create_task())
|
||||
gate = asyncio.Event()
|
||||
frame = _make_frame("event data")
|
||||
|
||||
@tool
|
||||
async def blocking_tool(self, params):
|
||||
"""Blocks until gate is set."""
|
||||
await gate.wait()
|
||||
|
||||
wrapped = task._track_tool_call(blocking_tool.__get__(task))
|
||||
params = MagicMock()
|
||||
|
||||
runner_task = asyncio.create_task(wrapped(params))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await task.queue_frame(frame)
|
||||
self.assertEqual(len(_get_delivered_frames(task)), 0)
|
||||
|
||||
gate.set()
|
||||
await runner_task
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertIs(delivered[0][0], frame)
|
||||
|
||||
async def test_concurrent_tools_flush_only_when_all_done(self):
|
||||
"""With two parallel tools, flush happens only when the last one completes."""
|
||||
task = self._track(_create_task())
|
||||
gate_a = asyncio.Event()
|
||||
gate_b = asyncio.Event()
|
||||
|
||||
@tool
|
||||
async def tool_a(self, params):
|
||||
"""First tool."""
|
||||
await gate_a.wait()
|
||||
|
||||
@tool
|
||||
async def tool_b(self, params):
|
||||
"""Second tool."""
|
||||
await gate_b.wait()
|
||||
|
||||
wrapped_a = task._track_tool_call(tool_a.__get__(task))
|
||||
wrapped_b = task._track_tool_call(tool_b.__get__(task))
|
||||
params = MagicMock()
|
||||
|
||||
task_a = asyncio.create_task(wrapped_a(params))
|
||||
task_b = asyncio.create_task(wrapped_b(params))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(task._tool_call_inflight, 2)
|
||||
|
||||
frame = _make_frame("queued")
|
||||
await task.queue_frame(frame)
|
||||
|
||||
# First tool finishes — frame still deferred (second tool running)
|
||||
gate_a.set()
|
||||
await task_a
|
||||
self.assertEqual(task._tool_call_inflight, 1)
|
||||
self.assertEqual(len(_get_delivered_frames(task)), 0)
|
||||
|
||||
# Second tool finishes — NOW flush
|
||||
gate_b.set()
|
||||
await task_b
|
||||
self.assertEqual(task._tool_call_inflight, 0)
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertIs(delivered[0][0], frame)
|
||||
|
||||
async def test_queue_frame_preserves_frame_attributes(self):
|
||||
"""Frame attributes like run_llm are preserved through defer and flush."""
|
||||
task = self._track(_create_task())
|
||||
gate = asyncio.Event()
|
||||
|
||||
@tool
|
||||
async def blocking_tool(self, params):
|
||||
"""Blocks."""
|
||||
await gate.wait()
|
||||
|
||||
wrapped = task._track_tool_call(blocking_tool.__get__(task))
|
||||
params = MagicMock()
|
||||
|
||||
runner_task = asyncio.create_task(wrapped(params))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
frame = _make_frame("no inference", run_llm=False)
|
||||
await task.queue_frame(frame)
|
||||
|
||||
gate.set()
|
||||
await runner_task
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertFalse(delivered[0][0].run_llm)
|
||||
|
||||
async def test_multiple_deferred_frames_flush_in_order(self):
|
||||
"""Multiple deferred frames are delivered in FIFO order."""
|
||||
task = self._track(_create_task())
|
||||
gate = asyncio.Event()
|
||||
|
||||
@tool
|
||||
async def blocking_tool(self, params):
|
||||
"""Blocks."""
|
||||
await gate.wait()
|
||||
|
||||
wrapped = task._track_tool_call(blocking_tool.__get__(task))
|
||||
params = MagicMock()
|
||||
|
||||
runner_task = asyncio.create_task(wrapped(params))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
frame_a = _make_frame("first", run_llm=False)
|
||||
frame_b = _make_frame("second", run_llm=True)
|
||||
await task.queue_frame(frame_a)
|
||||
await task.queue_frame(frame_b)
|
||||
|
||||
gate.set()
|
||||
await runner_task
|
||||
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 2)
|
||||
self.assertIs(delivered[0][0], frame_a)
|
||||
self.assertIs(delivered[1][0], frame_b)
|
||||
|
||||
async def test_tool_error_still_decrements_and_flushes(self):
|
||||
"""If a tool raises, the counter still decrements and deferred frames flush."""
|
||||
task = self._track(_create_task())
|
||||
|
||||
@tool
|
||||
async def failing_tool(self, params):
|
||||
"""Always fails."""
|
||||
raise ValueError("boom")
|
||||
|
||||
wrapped = task._track_tool_call(failing_tool.__get__(task))
|
||||
params = MagicMock()
|
||||
|
||||
frame = _make_frame("recover")
|
||||
task._tool_call_inflight = 1
|
||||
await task.queue_frame(frame)
|
||||
task._tool_call_inflight = 0
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
await wrapped(params)
|
||||
|
||||
self.assertFalse(task.tool_call_active)
|
||||
delivered = _get_delivered_frames(task)
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertIs(delivered[0][0], frame)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
97
tests/test_messages.py
Normal file
97
tests/test_messages.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import (
|
||||
AsyncQueueBus,
|
||||
BusDataMessage,
|
||||
BusFrameMessage,
|
||||
BusSubscriber,
|
||||
)
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
|
||||
class TestBusMessageRouting(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_broadcast_message_no_target(self):
|
||||
"""A BusMessage with no target is broadcast (target is None)."""
|
||||
msg = BusDataMessage(source="task_a")
|
||||
self.assertIsNone(msg.target)
|
||||
|
||||
async def test_targeted_message(self):
|
||||
"""A BusMessage with target set is only for that task."""
|
||||
msg = BusDataMessage(source="task_a", target="task_b")
|
||||
self.assertEqual(msg.target, "task_b")
|
||||
|
||||
async def test_broadcast_reaches_all_subscribers(self):
|
||||
"""Broadcast messages (no target) reach all subscribers."""
|
||||
bus = AsyncQueueBus()
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
received_a = []
|
||||
received_b = []
|
||||
|
||||
class SubA(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "sub_a"
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
received_a.append(message)
|
||||
|
||||
class SubB(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "sub_b"
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
received_b.append(message)
|
||||
|
||||
await bus.subscribe(SubA())
|
||||
await bus.subscribe(SubB())
|
||||
|
||||
await bus.start()
|
||||
msg = BusDataMessage(source="task_x") # no target
|
||||
await bus.send(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
await bus.stop()
|
||||
|
||||
# Both subscribers see the broadcast
|
||||
self.assertEqual(len(received_a), 1)
|
||||
self.assertEqual(len(received_b), 1)
|
||||
|
||||
async def test_bus_frame_message_wraps_frame(self):
|
||||
"""BusFrameMessage wraps a frame with source and direction."""
|
||||
frame = TextFrame(text="hello")
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=frame,
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
self.assertIs(msg.frame, frame)
|
||||
self.assertEqual(msg.direction, FrameDirection.DOWNSTREAM)
|
||||
self.assertEqual(msg.source, "task_a")
|
||||
self.assertIsNone(msg.target)
|
||||
|
||||
async def test_bus_frame_message_with_target(self):
|
||||
"""BusFrameMessage can carry a target for directed delivery."""
|
||||
frame = TextFrame(text="hello")
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
target="task_b",
|
||||
frame=frame,
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
self.assertEqual(msg.target, "task_b")
|
||||
self.assertIs(msg.frame, frame)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
266
tests/test_pgmq_backends.py
Normal file
266
tests/test_pgmq_backends.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Unit tests for the PgmqBus backend layer.
|
||||
|
||||
Covers:
|
||||
|
||||
- ``IsolatedPgmqBackend`` issues the expected ``public.bus_*`` SQL with the
|
||||
expected positional args, and normalizes the read result shape.
|
||||
- ``PgmqBus`` correctly delegates to an injected backend (the
|
||||
``backend=`` constructor path) and rejects ambiguous / empty construction.
|
||||
|
||||
``DirectPgmqBackend`` is exercised end-to-end via ``test_pgmq_bus.py``
|
||||
against an in-memory ``FakePgmq``; no separate tests for it here.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pipecat.bus import BusDataMessage
|
||||
from pipecat.bus.network.pgmq import PgmqBus
|
||||
from pipecat.bus.network.pgmq_backends import (
|
||||
BackendMessage,
|
||||
IsolatedPgmqBackend,
|
||||
PgmqBackend,
|
||||
)
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IsolatedPgmqBackend: assert it speaks the right SQL to asyncpg.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Call:
|
||||
method: str # 'fetchval' | 'execute' | 'fetch'
|
||||
sql: str
|
||||
args: tuple
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, recorder, fetchval_return=None, fetch_return=None):
|
||||
self._recorder = recorder
|
||||
self._fetchval_return = fetchval_return
|
||||
self._fetch_return = fetch_return or []
|
||||
|
||||
async def fetchval(self, sql, *args):
|
||||
self._recorder.append(_Call("fetchval", sql, args))
|
||||
return self._fetchval_return
|
||||
|
||||
async def execute(self, sql, *args):
|
||||
self._recorder.append(_Call("execute", sql, args))
|
||||
return "OK"
|
||||
|
||||
async def fetch(self, sql, *args):
|
||||
self._recorder.append(_Call("fetch", sql, args))
|
||||
return self._fetch_return
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, fetchval_return=None, fetch_return=None):
|
||||
self.calls: list[_Call] = []
|
||||
self._fetchval_return = fetchval_return
|
||||
self._fetch_return = fetch_return or []
|
||||
|
||||
def acquire(self):
|
||||
recorder = self.calls
|
||||
fetchval_return = self._fetchval_return
|
||||
fetch_return = self._fetch_return
|
||||
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner):
|
||||
return _FakeConn(recorder, fetchval_return, fetch_return)
|
||||
|
||||
async def __aexit__(self_inner, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
class TestIsolatedPgmqBackend(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_join_calls_bus_join_and_returns_queue_name(self):
|
||||
pool = _FakePool(fetchval_return="q_abc123")
|
||||
backend = IsolatedPgmqBackend(pool=pool)
|
||||
|
||||
queue = await backend.join("ch_xyz")
|
||||
|
||||
self.assertEqual(queue, "q_abc123")
|
||||
self.assertEqual(len(pool.calls), 1)
|
||||
self.assertIn("public.bus_join", pool.calls[0].sql)
|
||||
self.assertEqual(pool.calls[0].args, ("ch_xyz",))
|
||||
|
||||
async def test_join_raises_when_wrapper_returns_empty(self):
|
||||
pool = _FakePool(fetchval_return=None)
|
||||
backend = IsolatedPgmqBackend(pool=pool)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await backend.join("ch_xyz")
|
||||
|
||||
async def test_publish_serializes_payload_as_jsonb(self):
|
||||
pool = _FakePool()
|
||||
backend = IsolatedPgmqBackend(pool=pool)
|
||||
|
||||
await backend.publish("ch_xyz", "q_self", {"k": "v", "n": 1})
|
||||
|
||||
self.assertEqual(len(pool.calls), 1)
|
||||
call = pool.calls[0]
|
||||
self.assertIn("public.bus_publish", call.sql)
|
||||
# (channel, my_queue, json_text)
|
||||
self.assertEqual(call.args[0], "ch_xyz")
|
||||
self.assertEqual(call.args[1], "q_self")
|
||||
self.assertEqual(json.loads(call.args[2]), {"k": "v", "n": 1})
|
||||
|
||||
async def test_read_normalizes_rows_and_decodes_string_jsonb(self):
|
||||
# asyncpg can hand back jsonb as either a str (when no codec is
|
||||
# registered) or a dict (with a codec). Both should normalize.
|
||||
pool = _FakePool(
|
||||
fetch_return=[
|
||||
{"msg_id": 1, "message": {"hello": "world"}},
|
||||
{"msg_id": 2, "message": json.dumps({"hello": "again"})},
|
||||
]
|
||||
)
|
||||
backend = IsolatedPgmqBackend(pool=pool)
|
||||
|
||||
msgs = await backend.read(
|
||||
"q_self",
|
||||
channel="ch_xyz",
|
||||
vt=30,
|
||||
qty=10,
|
||||
max_poll_seconds=5,
|
||||
poll_interval_ms=100,
|
||||
)
|
||||
|
||||
self.assertEqual(len(msgs), 2)
|
||||
self.assertIsInstance(msgs[0], BackendMessage)
|
||||
self.assertEqual(msgs[0].msg_id, 1)
|
||||
self.assertEqual(msgs[0].message, {"hello": "world"})
|
||||
self.assertEqual(msgs[1].msg_id, 2)
|
||||
self.assertEqual(msgs[1].message, {"hello": "again"})
|
||||
|
||||
self.assertEqual(len(pool.calls), 1)
|
||||
call = pool.calls[0]
|
||||
self.assertIn("public.bus_subscribe", call.sql)
|
||||
# (my_queue, channel, vt, qty, max_poll_seconds)
|
||||
self.assertEqual(call.args, ("q_self", "ch_xyz", 30, 10, 5))
|
||||
|
||||
async def test_archive_and_leave_call_the_right_wrappers(self):
|
||||
pool = _FakePool(fetchval_return=True)
|
||||
backend = IsolatedPgmqBackend(pool=pool)
|
||||
|
||||
archived = await backend.archive("q_self", channel="ch_xyz", msg_id=42)
|
||||
self.assertTrue(archived)
|
||||
|
||||
await backend.leave("q_self", channel="ch_xyz")
|
||||
|
||||
self.assertEqual(len(pool.calls), 2)
|
||||
self.assertIn("public.bus_archive", pool.calls[0].sql)
|
||||
self.assertEqual(pool.calls[0].args, ("q_self", "ch_xyz", 42))
|
||||
self.assertIn("public.bus_leave", pool.calls[1].sql)
|
||||
self.assertEqual(pool.calls[1].args, ("q_self", "ch_xyz"))
|
||||
|
||||
def test_satisfies_pgmq_backend_protocol(self):
|
||||
# Runtime-checkable Protocol: confirm structural compliance.
|
||||
backend = IsolatedPgmqBackend(pool=_FakePool())
|
||||
self.assertIsInstance(backend, PgmqBackend)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PgmqBus(backend=...): orchestrator delegates to whatever backend it's given.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingBackend:
|
||||
"""A backend that records every call PgmqBus makes against it."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, tuple, dict]] = []
|
||||
self._inbound: asyncio.Queue[BackendMessage] = asyncio.Queue()
|
||||
|
||||
async def join(self, channel):
|
||||
self.calls.append(("join", (channel,), {}))
|
||||
return "q_injected"
|
||||
|
||||
async def publish(self, channel, my_queue, payload):
|
||||
self.calls.append(("publish", (channel, my_queue, payload), {}))
|
||||
|
||||
async def read(self, queue, *, channel, vt, qty, max_poll_seconds, poll_interval_ms):
|
||||
self.calls.append(
|
||||
(
|
||||
"read",
|
||||
(queue,),
|
||||
dict(
|
||||
channel=channel,
|
||||
vt=vt,
|
||||
qty=qty,
|
||||
max_poll_seconds=max_poll_seconds,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
),
|
||||
)
|
||||
)
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._inbound.get(), timeout=0.05)
|
||||
return [msg]
|
||||
except TimeoutError:
|
||||
return []
|
||||
|
||||
async def archive(self, queue, *, channel, msg_id):
|
||||
self.calls.append(("archive", (queue,), dict(channel=channel, msg_id=msg_id)))
|
||||
return True
|
||||
|
||||
async def leave(self, queue, *, channel):
|
||||
self.calls.append(("leave", (queue,), dict(channel=channel)))
|
||||
|
||||
|
||||
class TestPgmqBusBackendInjection(unittest.IsolatedAsyncioTestCase):
|
||||
def test_rejects_construction_with_neither_pgmq_nor_backend(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PgmqBus(channel="ch_xyz")
|
||||
|
||||
def test_rejects_construction_with_both_pgmq_and_backend(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PgmqBus(pgmq=object(), backend=_RecordingBackend(), channel="ch_xyz")
|
||||
|
||||
async def test_lifecycle_and_publish_delegate_to_backend(self):
|
||||
backend = _RecordingBackend()
|
||||
bus = PgmqBus(
|
||||
backend=backend,
|
||||
channel="ch_xyz",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
|
||||
await bus.start()
|
||||
try:
|
||||
self.assertEqual(bus._queue_name, "q_injected")
|
||||
await bus.send(BusDataMessage(source="task_a"))
|
||||
# Give the (mostly idle) reader a chance to record a read call.
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
# join + publish (at least 1) + leave must all be observed; read may
|
||||
# have fired multiple times, archive never (no inbound msg).
|
||||
methods = [c[0] for c in backend.calls]
|
||||
self.assertEqual(methods.count("join"), 1)
|
||||
self.assertGreaterEqual(methods.count("publish"), 1)
|
||||
self.assertEqual(methods.count("leave"), 1)
|
||||
self.assertEqual(methods.count("archive"), 0)
|
||||
|
||||
# The publish call passed (channel, my_queue, payload-dict).
|
||||
publish_call = next(c for c in backend.calls if c[0] == "publish")
|
||||
channel, my_queue, payload = publish_call[1]
|
||||
self.assertEqual(channel, "ch_xyz")
|
||||
self.assertEqual(my_queue, "q_injected")
|
||||
self.assertIsInstance(payload, dict)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
611
tests/test_pgmq_bus.py
Normal file
611
tests/test_pgmq_bus.py
Normal file
@@ -0,0 +1,611 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from pipecat.bus import (
|
||||
BusAddTaskMessage,
|
||||
BusDataMessage,
|
||||
BusEndMessage,
|
||||
BusFrameMessage,
|
||||
BusJobRequestMessage,
|
||||
BusSubscriber,
|
||||
)
|
||||
from pipecat.bus.network.pgmq import PgmqBus
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
_sub_counter = itertools.count()
|
||||
|
||||
|
||||
def _make_sub(received: list) -> BusSubscriber:
|
||||
"""Create a BusSubscriber that appends messages to the given list."""
|
||||
sub_name = f"test_sub_{next(_sub_counter)}"
|
||||
|
||||
class _Sub(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return sub_name
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
received.append(message)
|
||||
|
||||
return _Sub()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMessage:
|
||||
msg_id: int
|
||||
message: dict
|
||||
read_ct: int = 0
|
||||
enqueued_at: datetime = field(default_factory=datetime.utcnow)
|
||||
vt: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class FakePgmq:
|
||||
"""In-memory fake PGMQueue covering the surface used by ``PgmqBus``."""
|
||||
|
||||
def __init__(self):
|
||||
self._queues: dict[str, list[_FakeMessage]] = {}
|
||||
self._next_id = 1
|
||||
self.sent: list[tuple[str, dict]] = []
|
||||
|
||||
async def create_queue(self, queue: str, unlogged: bool = False) -> None:
|
||||
self._queues.setdefault(queue, [])
|
||||
|
||||
async def drop_queue(self, queue: str, partitioned: bool = False) -> bool:
|
||||
existed = queue in self._queues
|
||||
self._queues.pop(queue, None)
|
||||
return existed
|
||||
|
||||
async def list_queues(self) -> list[str]:
|
||||
return list(self._queues.keys())
|
||||
|
||||
async def send(self, queue: str, message: dict, delay: int = 0, tz=None) -> int:
|
||||
if queue not in self._queues:
|
||||
raise RuntimeError(f"queue '{queue}' does not exist")
|
||||
msg_id = self._next_id
|
||||
self._next_id += 1
|
||||
self._queues[queue].append(_FakeMessage(msg_id=msg_id, message=message))
|
||||
self.sent.append((queue, message))
|
||||
return msg_id
|
||||
|
||||
async def read_with_poll(
|
||||
self,
|
||||
queue: str,
|
||||
vt: int | None = None,
|
||||
qty: int = 1,
|
||||
max_poll_seconds: int = 5,
|
||||
poll_interval_ms: int = 100,
|
||||
) -> list[_FakeMessage]:
|
||||
elapsed = 0.0
|
||||
interval = poll_interval_ms / 1000.0
|
||||
timeout = float(max_poll_seconds)
|
||||
while elapsed < timeout:
|
||||
queue_msgs = self._queues.get(queue)
|
||||
if queue_msgs:
|
||||
taken = queue_msgs[:qty]
|
||||
# Mark as read by removing for now (simulates vt with a long
|
||||
# enough vt that tests don't observe redelivery).
|
||||
self._queues[queue] = queue_msgs[qty:]
|
||||
return taken
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
return []
|
||||
|
||||
async def delete(self, queue: str, msg_id: int) -> bool:
|
||||
# Messages in this fake are removed from the visible queue when read.
|
||||
# delete() is essentially a no-op for tracking, but we record the call.
|
||||
return True
|
||||
|
||||
def inject(self, queue: str, message: dict) -> int:
|
||||
"""Push a message into a queue without going through send()."""
|
||||
if queue not in self._queues:
|
||||
self._queues[queue] = []
|
||||
msg_id = self._next_id
|
||||
self._next_id += 1
|
||||
self._queues[queue].append(_FakeMessage(msg_id=msg_id, message=message))
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_test_pgmq_bus(
|
||||
channel: str = "test_bus",
|
||||
) -> tuple[PgmqBus, FakePgmq, JSONMessageSerializer]:
|
||||
"""Create a PgmqBus with FakePgmq and TaskManager for testing."""
|
||||
pgmq = FakePgmq()
|
||||
serializer = JSONMessageSerializer()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
serializer=serializer,
|
||||
channel=channel,
|
||||
# Tight timing so tests run fast
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
return bus, pgmq, serializer
|
||||
|
||||
|
||||
class TestPgmqBus(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.pgmq, self.serializer = await create_test_pgmq_bus()
|
||||
await self.bus.start()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.bus.stop()
|
||||
|
||||
async def test_send_publishes_to_pgmq(self):
|
||||
"""send() serializes and publishes to the PGMQ queue."""
|
||||
msg = BusDataMessage(source="task_a", target="task_b")
|
||||
await self.bus.send(msg)
|
||||
|
||||
self.assertEqual(len(self.pgmq.sent), 1)
|
||||
queue, payload = self.pgmq.sent[0]
|
||||
self.assertTrue(queue.startswith("test_bus_"))
|
||||
self.assertIsInstance(payload, dict)
|
||||
|
||||
restored = self.serializer.deserialize(json.dumps(payload).encode("utf-8"))
|
||||
self.assertEqual(restored.source, "task_a")
|
||||
self.assertEqual(restored.target, "task_b")
|
||||
|
||||
async def test_local_message_stays_local(self):
|
||||
"""BusLocalMessage messages are delivered locally but not over PGMQ."""
|
||||
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
|
||||
task = BaseTask("test")
|
||||
msg = BusAddTaskMessage(source="parent", task=task)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Not published to PGMQ
|
||||
self.assertEqual(len(self.pgmq.sent), 0)
|
||||
# But delivered locally
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIsInstance(received[0], BusAddTaskMessage)
|
||||
self.assertIs(received[0].task, task)
|
||||
|
||||
async def test_round_trip_via_subscriber(self):
|
||||
"""Messages published are received by subscribers."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
|
||||
msg = BusEndMessage(source="task_a", reason="done")
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIsInstance(received[0], BusEndMessage)
|
||||
self.assertEqual(received[0].source, "task_a")
|
||||
self.assertEqual(received[0].reason, "done")
|
||||
|
||||
async def test_multiple_subscribers_receive(self):
|
||||
"""Multiple subscribers each receive every message."""
|
||||
received_a = []
|
||||
received_b = []
|
||||
await self.bus.subscribe(_make_sub(received_a))
|
||||
await self.bus.subscribe(_make_sub(received_b))
|
||||
|
||||
msg = BusDataMessage(source="x")
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
self.assertEqual(len(received_a), 1)
|
||||
self.assertEqual(len(received_b), 1)
|
||||
|
||||
async def test_frame_message_round_trip(self):
|
||||
"""BusFrameMessage with a frame adapter round-trips through PGMQ."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=TextFrame(text="hello"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
restored = received[0]
|
||||
self.assertIsInstance(restored, BusFrameMessage)
|
||||
self.assertIsInstance(restored.frame, TextFrame)
|
||||
self.assertEqual(restored.frame.text, "hello")
|
||||
self.assertEqual(restored.direction, FrameDirection.DOWNSTREAM)
|
||||
|
||||
async def test_job_request_round_trip(self):
|
||||
"""BusJobRequestMessage round-trips through PGMQ."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
|
||||
msg = BusJobRequestMessage(
|
||||
source="parent",
|
||||
target="worker",
|
||||
job_id="t-1",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
restored = received[0]
|
||||
self.assertIsInstance(restored, BusJobRequestMessage)
|
||||
self.assertEqual(restored.job_id, "t-1")
|
||||
self.assertEqual(restored.payload, {"key": "value"})
|
||||
|
||||
|
||||
class TestPgmqBusBroadcast(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.pgmq = FakePgmq()
|
||||
self.serializer = JSONMessageSerializer()
|
||||
self.tm = TaskManager()
|
||||
self.tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
|
||||
self.bus_a = PgmqBus(
|
||||
pgmq=self.pgmq,
|
||||
serializer=self.serializer,
|
||||
channel="test_bus",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
await self.bus_a.setup(self.tm)
|
||||
|
||||
self.bus_b = PgmqBus(
|
||||
pgmq=self.pgmq,
|
||||
serializer=self.serializer,
|
||||
channel="test_bus",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
await self.bus_b.setup(self.tm)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.bus_a.stop()
|
||||
await self.bus_b.stop()
|
||||
|
||||
async def test_broadcast_to_multiple_instances(self):
|
||||
"""Publishing on one instance reaches subscribers on a peer instance."""
|
||||
await self.bus_a.start()
|
||||
await self.bus_b.start()
|
||||
|
||||
received_a = []
|
||||
received_b = []
|
||||
await self.bus_a.subscribe(_make_sub(received_a))
|
||||
await self.bus_b.subscribe(_make_sub(received_b))
|
||||
|
||||
# bus_a publishes — both bus_a and bus_b should receive (round-trip).
|
||||
msg = BusEndMessage(source="task_a", reason="hello peers")
|
||||
await self.bus_a.send(msg)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
self.assertEqual(len(received_a), 1)
|
||||
self.assertEqual(len(received_b), 1)
|
||||
self.assertIsInstance(received_a[0], BusEndMessage)
|
||||
self.assertIsInstance(received_b[0], BusEndMessage)
|
||||
|
||||
async def test_publish_skips_dropped_queue(self):
|
||||
"""If a peer queue disappears mid-publish the broadcast keeps going."""
|
||||
await self.bus_a.start()
|
||||
await self.bus_b.start()
|
||||
|
||||
received_b = []
|
||||
await self.bus_b.subscribe(_make_sub(received_b))
|
||||
|
||||
# Drop bus_a's own queue out from under it (simulates a crashed peer).
|
||||
await self.pgmq.drop_queue(self.bus_a._queue_name)
|
||||
|
||||
msg = BusDataMessage(source="task_a")
|
||||
# Should not raise even though one of the two peer queues is gone.
|
||||
await self.bus_a.send(msg)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# bus_b still received the message
|
||||
self.assertEqual(len(received_b), 1)
|
||||
|
||||
|
||||
class TestPgmqBusEdgeCases(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_custom_channel(self):
|
||||
"""Channel parameter is honored in the queue name."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
channel="custom_channel",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
self.assertTrue(bus._queue_name.startswith("custom_channel_"))
|
||||
await bus.stop()
|
||||
|
||||
async def test_channel_sanitization(self):
|
||||
"""Invalid channel characters are replaced with underscores."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
channel="pipecat:acme/v2",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
|
||||
# Queue name must match PGMQ's allowed identifier pattern.
|
||||
self.assertRegex(bus._queue_name, r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# And contain the sanitized prefix.
|
||||
self.assertTrue(bus._queue_name.startswith("pipecat_acme_v2_"))
|
||||
|
||||
await bus.stop()
|
||||
|
||||
async def test_stop_cleans_up(self):
|
||||
"""stop() cancels the reader task and drops the queue."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="cleanup_test", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
|
||||
await bus.start()
|
||||
queue_name = bus._queue_name
|
||||
self.assertIsNotNone(queue_name)
|
||||
self.assertIsNotNone(bus._reader_task)
|
||||
self.assertIn(queue_name, await pgmq.list_queues())
|
||||
|
||||
await bus.stop()
|
||||
self.assertIsNone(bus._queue_name)
|
||||
self.assertIsNone(bus._reader_task)
|
||||
self.assertNotIn(queue_name, await pgmq.list_queues())
|
||||
|
||||
async def test_deserialize_failure_is_handled(self):
|
||||
"""A bad payload on the queue is logged and the reader keeps going."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
channel="poison_test",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
|
||||
received = []
|
||||
await bus.subscribe(_make_sub(received))
|
||||
await bus.start()
|
||||
|
||||
# Inject a bogus payload directly into the bus's own queue.
|
||||
pgmq.inject(bus._queue_name, {"not": "a real bus message"})
|
||||
|
||||
# Then send a real message through the normal path.
|
||||
await bus.send(BusEndMessage(source="task_a", reason="recovered"))
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# The poison message should be silently dropped; the real one delivered.
|
||||
real = [m for m in received if isinstance(m, BusEndMessage)]
|
||||
self.assertEqual(len(real), 1)
|
||||
self.assertEqual(real[0].reason, "recovered")
|
||||
|
||||
await bus.stop()
|
||||
|
||||
|
||||
class TestPgmqBusErrorPaths(unittest.IsolatedAsyncioTestCase):
|
||||
"""Cover error and edge branches in ``PgmqBus``."""
|
||||
|
||||
async def test_sanitize_channel_leading_digit(self):
|
||||
"""A channel starting with a digit gets a ``q_`` prefix."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="123corp", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
self.assertTrue(bus._queue_name.startswith("q_123corp_"))
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
async def test_peer_cache_hit_on_rapid_publish(self):
|
||||
"""A second publish within the TTL reuses the cached peer list."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="cache_hit", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
list_calls = {"n": 0}
|
||||
real_list_queues = pgmq.list_queues
|
||||
|
||||
async def counting_list_queues():
|
||||
list_calls["n"] += 1
|
||||
return await real_list_queues()
|
||||
|
||||
pgmq.list_queues = counting_list_queues
|
||||
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
first = list_calls["n"]
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
self.assertEqual(list_calls["n"], first, "second publish must hit cache")
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
async def test_publish_skips_when_serializer_returns_invalid_json(self):
|
||||
"""If the serializer's bytes aren't decodable JSON, publish bails out."""
|
||||
|
||||
class BadSerializer(JSONMessageSerializer):
|
||||
def serialize(self, message):
|
||||
return b"not-valid-json"
|
||||
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
serializer=BadSerializer(),
|
||||
channel="bad_serializer",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
# Nothing should land on the wire because json.loads failed.
|
||||
self.assertEqual(len(pgmq.sent), 0)
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
async def test_publish_handles_send_failure_to_cached_peer(self):
|
||||
"""A queue dropped after the cache warmed lets publish keep going."""
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
channel="cached_drop",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
# Warm the peer cache with the bus's own queue.
|
||||
await bus.send(BusDataMessage(source="warmup"))
|
||||
self.assertIn("cached_drop", bus._backend._peer_cache)
|
||||
# Drop our queue so the cached entry now points at nothing.
|
||||
await pgmq.drop_queue(bus._queue_name)
|
||||
# Force-keep the cache fresh so we hit the cached path on the next publish.
|
||||
cached_at, peers = bus._backend._peer_cache["cached_drop"]
|
||||
bus._backend._peer_cache["cached_drop"] = (float("inf"), peers)
|
||||
# Should swallow the exception from the dropped peer and reset the cache.
|
||||
await bus.send(BusDataMessage(source="dead-letter"))
|
||||
self.assertNotIn("cached_drop", bus._backend._peer_cache)
|
||||
finally:
|
||||
# Re-create the queue so stop() can drop it cleanly without surfacing.
|
||||
await pgmq.create_queue(bus._queue_name)
|
||||
await bus.stop()
|
||||
|
||||
async def test_stop_swallows_drop_queue_failure(self):
|
||||
"""``stop()`` logs but doesn't raise when ``drop_queue`` fails."""
|
||||
|
||||
class BadDropPgmq(FakePgmq):
|
||||
async def drop_queue(self, queue, partitioned=False):
|
||||
raise RuntimeError("simulated drop failure")
|
||||
|
||||
pgmq = BadDropPgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="bad_drop", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
|
||||
# Should not raise even though drop_queue throws.
|
||||
await bus.stop()
|
||||
self.assertIsNone(bus._queue_name)
|
||||
|
||||
async def test_reader_loop_recovers_from_read_failure(self):
|
||||
"""A failure inside ``read_with_poll`` triggers backoff, not crash."""
|
||||
|
||||
class FlakyReadPgmq(FakePgmq):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.read_calls = 0
|
||||
|
||||
async def read_with_poll(self, *args, **kwargs):
|
||||
self.read_calls += 1
|
||||
if self.read_calls == 1:
|
||||
raise RuntimeError("simulated read failure")
|
||||
return await super().read_with_poll(*args, **kwargs)
|
||||
|
||||
pgmq = FlakyReadPgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="flaky_read", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
# Give the reader long enough to fail once and back off.
|
||||
await asyncio.sleep(1.2)
|
||||
self.assertGreaterEqual(pgmq.read_calls, 2)
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
async def test_reader_loop_handles_deserializer_exception(self):
|
||||
"""If the serializer raises while decoding, the message is still deleted."""
|
||||
|
||||
class RaisingSerializer(JSONMessageSerializer):
|
||||
def deserialize(self, data):
|
||||
raise RuntimeError("simulated deserialize crash")
|
||||
|
||||
pgmq = FakePgmq()
|
||||
bus = PgmqBus(
|
||||
pgmq=pgmq,
|
||||
serializer=RaisingSerializer(),
|
||||
channel="bad_deserialize",
|
||||
poll_interval_ms=10,
|
||||
max_poll_seconds=1,
|
||||
)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
await bus.start()
|
||||
try:
|
||||
pgmq.inject(bus._queue_name, {"junk": True})
|
||||
# Reader should consume + log + continue without crashing.
|
||||
await asyncio.sleep(0.3)
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
async def test_reader_loop_logs_when_delete_fails(self):
|
||||
"""A failing ``delete`` after dispatch is logged and not propagated."""
|
||||
|
||||
class BadDeletePgmq(FakePgmq):
|
||||
async def delete(self, queue, msg_id):
|
||||
raise RuntimeError("simulated delete failure")
|
||||
|
||||
pgmq = BadDeletePgmq()
|
||||
bus = PgmqBus(pgmq=pgmq, channel="bad_delete", poll_interval_ms=10, max_poll_seconds=1)
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
|
||||
received = []
|
||||
await bus.subscribe(_make_sub(received))
|
||||
await bus.start()
|
||||
try:
|
||||
await bus.send(BusEndMessage(source="a", reason="ok"))
|
||||
await asyncio.sleep(0.3)
|
||||
# Real message still delivered locally despite delete failing.
|
||||
self.assertEqual(len(received), 1)
|
||||
finally:
|
||||
await bus.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
249
tests/test_redis_bus.py
Normal file
249
tests/test_redis_bus.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import (
|
||||
BusAddTaskMessage,
|
||||
BusDataMessage,
|
||||
BusEndMessage,
|
||||
BusFrameMessage,
|
||||
BusJobRequestMessage,
|
||||
BusSubscriber,
|
||||
)
|
||||
from pipecat.bus.network.redis import RedisBus
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
_sub_counter = itertools.count()
|
||||
|
||||
|
||||
def _make_sub(received: list) -> BusSubscriber:
|
||||
"""Create a BusSubscriber that appends messages to the given list."""
|
||||
sub_name = f"test_sub_{next(_sub_counter)}"
|
||||
|
||||
class _Sub(BusSubscriber):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return sub_name
|
||||
|
||||
async def on_bus_message(self, message):
|
||||
received.append(message)
|
||||
|
||||
return _Sub()
|
||||
|
||||
|
||||
class FakePubSub:
|
||||
"""In-memory fake Redis pub/sub for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._subscriptions: dict[str, asyncio.Queue] = {}
|
||||
self._closed = False
|
||||
|
||||
async def subscribe(self, channel: str):
|
||||
self._subscriptions[channel] = asyncio.Queue()
|
||||
|
||||
async def unsubscribe(self, channel: str):
|
||||
self._subscriptions.pop(channel, None)
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
|
||||
async def listen(self):
|
||||
"""Yield messages from the subscription queue."""
|
||||
# We only support one channel in tests
|
||||
channel = next(iter(self._subscriptions))
|
||||
queue = self._subscriptions[channel]
|
||||
while True:
|
||||
msg = await queue.get()
|
||||
yield msg
|
||||
|
||||
def inject(self, channel: str, data: bytes):
|
||||
"""Inject a raw message into the fake pub/sub."""
|
||||
if channel in self._subscriptions:
|
||||
self._subscriptions[channel].put_nowait(
|
||||
{"type": "message", "data": data, "channel": channel}
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""In-memory fake Redis client for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._pubsubs: list[FakePubSub] = []
|
||||
self._published: list[tuple[str, bytes]] = []
|
||||
|
||||
def pubsub(self):
|
||||
ps = FakePubSub()
|
||||
self._pubsubs.append(ps)
|
||||
return ps
|
||||
|
||||
async def publish(self, channel: str, data: bytes):
|
||||
self._published.append((channel, data))
|
||||
# Fan out to all pubsub instances
|
||||
for ps in self._pubsubs:
|
||||
ps.inject(channel, data)
|
||||
|
||||
|
||||
async def create_test_redis_bus():
|
||||
"""Create a RedisBus with fake Redis and TaskManager for testing."""
|
||||
redis = FakeRedis()
|
||||
serializer = JSONMessageSerializer()
|
||||
bus = RedisBus(redis=redis, serializer=serializer, channel="test:bus")
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
return bus, redis, serializer
|
||||
|
||||
|
||||
class TestRedisBus(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.redis, self.serializer = await create_test_redis_bus()
|
||||
|
||||
async def test_send_publishes_to_redis(self):
|
||||
"""send() serializes and publishes to the Redis channel."""
|
||||
msg = BusDataMessage(source="task_a", target="task_b")
|
||||
await self.bus.send(msg)
|
||||
|
||||
self.assertEqual(len(self.redis._published), 1)
|
||||
channel, data = self.redis._published[0]
|
||||
self.assertEqual(channel, "test:bus")
|
||||
self.assertIsInstance(data, bytes)
|
||||
|
||||
# Verify it deserializes back
|
||||
restored = self.serializer.deserialize(data)
|
||||
self.assertEqual(restored.source, "task_a")
|
||||
self.assertEqual(restored.target, "task_b")
|
||||
|
||||
async def test_local_mixin_delivered_locally_not_to_redis(self):
|
||||
"""BusLocalMessage messages are delivered to local subscribers but not published to Redis."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
await self.bus.start()
|
||||
|
||||
task = BaseTask("test")
|
||||
msg = BusAddTaskMessage(source="parent", task=task)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
await self.bus.stop()
|
||||
|
||||
# Not published to Redis
|
||||
self.assertEqual(len(self.redis._published), 0)
|
||||
# But delivered locally
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIsInstance(received[0], BusAddTaskMessage)
|
||||
self.assertIs(received[0].task, task)
|
||||
|
||||
async def test_round_trip_via_subscriber(self):
|
||||
"""Messages published are received by subscribers."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
await self.bus.start()
|
||||
|
||||
msg = BusEndMessage(source="task_a", reason="done")
|
||||
await self.bus.send(msg)
|
||||
|
||||
# Give the reader task time to process
|
||||
await asyncio.sleep(0.1)
|
||||
await self.bus.stop()
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIsInstance(received[0], BusEndMessage)
|
||||
self.assertEqual(received[0].source, "task_a")
|
||||
self.assertEqual(received[0].reason, "done")
|
||||
|
||||
async def test_multiple_subscribers_receive(self):
|
||||
"""Multiple subscribers each receive every message."""
|
||||
received_a = []
|
||||
received_b = []
|
||||
await self.bus.subscribe(_make_sub(received_a))
|
||||
await self.bus.subscribe(_make_sub(received_b))
|
||||
await self.bus.start()
|
||||
|
||||
msg = BusDataMessage(source="x")
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await self.bus.stop()
|
||||
|
||||
self.assertEqual(len(received_a), 1)
|
||||
self.assertEqual(len(received_b), 1)
|
||||
|
||||
async def test_frame_message_round_trip(self):
|
||||
"""BusFrameMessage with a frame adapter round-trips through Redis."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
await self.bus.start()
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=TextFrame(text="hello"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await self.bus.stop()
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
restored = received[0]
|
||||
self.assertIsInstance(restored, BusFrameMessage)
|
||||
self.assertIsInstance(restored.frame, TextFrame)
|
||||
self.assertEqual(restored.frame.text, "hello")
|
||||
self.assertEqual(restored.direction, FrameDirection.DOWNSTREAM)
|
||||
|
||||
async def test_job_request_round_trip(self):
|
||||
"""BusJobRequestMessage round-trips through Redis."""
|
||||
received = []
|
||||
await self.bus.subscribe(_make_sub(received))
|
||||
await self.bus.start()
|
||||
|
||||
msg = BusJobRequestMessage(
|
||||
source="parent",
|
||||
target="worker",
|
||||
job_id="t-1",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
await self.bus.send(msg)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await self.bus.stop()
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
restored = received[0]
|
||||
self.assertIsInstance(restored, BusJobRequestMessage)
|
||||
self.assertEqual(restored.job_id, "t-1")
|
||||
self.assertEqual(restored.payload, {"key": "value"})
|
||||
|
||||
async def test_custom_channel(self):
|
||||
"""Messages are published to the configured channel."""
|
||||
bus = RedisBus(
|
||||
redis=self.redis,
|
||||
serializer=self.serializer,
|
||||
channel="custom:channel",
|
||||
)
|
||||
await bus.send(BusDataMessage(source="a"))
|
||||
self.assertEqual(self.redis._published[0][0], "custom:channel")
|
||||
|
||||
async def test_stop_cleans_up(self):
|
||||
"""stop() cancels the reader task and unsubscribes from Redis."""
|
||||
await self.bus.start()
|
||||
self.assertIsNotNone(self.bus._pubsub)
|
||||
self.assertIsNotNone(self.bus._reader_task)
|
||||
|
||||
await self.bus.stop()
|
||||
self.assertIsNone(self.bus._pubsub)
|
||||
self.assertIsNone(self.bus._reader_task)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
170
tests/test_registry.py
Normal file
170
tests/test_registry.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
from pipecat.registry import TaskRegistry
|
||||
from pipecat.registry.types import TaskReadyData
|
||||
|
||||
|
||||
class TestTaskRegistry(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.registry = TaskRegistry(runner_name="runner_a")
|
||||
|
||||
async def test_register_local_task(self):
|
||||
"""Local task is registered and appears in local_tasks."""
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
result = await self.registry.register(data)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertIn("greeter", self.registry.local_tasks)
|
||||
self.assertNotIn("greeter", self.registry.remote_tasks)
|
||||
|
||||
async def test_register_remote_task(self):
|
||||
"""Remote task is registered and appears in remote_tasks."""
|
||||
data = TaskReadyData(task_name="support", runner="runner_b")
|
||||
result = await self.registry.register(data)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertIn("support", self.registry.remote_tasks)
|
||||
self.assertNotIn("support", self.registry.local_tasks)
|
||||
|
||||
async def test_duplicate_registration_returns_false(self):
|
||||
"""Registering the same task twice returns False."""
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
first = await self.registry.register(data)
|
||||
second = await self.registry.register(data)
|
||||
|
||||
self.assertTrue(first)
|
||||
self.assertFalse(second)
|
||||
self.assertEqual(self.registry.local_tasks.count("greeter"), 1)
|
||||
|
||||
async def test_get_local_task(self):
|
||||
"""get() returns data for a local task."""
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
result = self.registry.get("greeter")
|
||||
self.assertIs(result, data)
|
||||
|
||||
async def test_get_remote_task(self):
|
||||
"""get() returns data for a remote task."""
|
||||
data = TaskReadyData(task_name="support", runner="runner_b")
|
||||
await self.registry.register(data)
|
||||
|
||||
result = self.registry.get("support")
|
||||
self.assertIs(result, data)
|
||||
|
||||
async def test_get_unknown_task_returns_none(self):
|
||||
"""get() returns None for an unknown task."""
|
||||
self.assertIsNone(self.registry.get("nonexistent"))
|
||||
|
||||
async def test_contains(self):
|
||||
"""__contains__ works for registered and unregistered tasks."""
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
self.assertIn("greeter", self.registry)
|
||||
self.assertNotIn("unknown", self.registry)
|
||||
|
||||
async def test_watch_fires_on_registration(self):
|
||||
"""Watch handler fires when the watched task registers."""
|
||||
received = []
|
||||
|
||||
async def handler(task_data):
|
||||
received.append(task_data)
|
||||
|
||||
await self.registry.watch("greeter", handler)
|
||||
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIs(received[0], data)
|
||||
|
||||
async def test_watch_does_not_fire_for_other_tasks(self):
|
||||
"""Watch handler does not fire for a different task."""
|
||||
received = []
|
||||
|
||||
async def handler(task_data):
|
||||
received.append(task_data)
|
||||
|
||||
await self.registry.watch("greeter", handler)
|
||||
|
||||
data = TaskReadyData(task_name="support", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
self.assertEqual(len(received), 0)
|
||||
|
||||
async def test_watch_does_not_fire_on_duplicate(self):
|
||||
"""Watch handler does not fire on duplicate registration."""
|
||||
received = []
|
||||
|
||||
async def handler(task_data):
|
||||
received.append(task_data)
|
||||
|
||||
await self.registry.watch("greeter", handler)
|
||||
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
await self.registry.register(data)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
|
||||
async def test_multiple_watchers(self):
|
||||
"""Multiple watch handlers fire for the same task."""
|
||||
received_a = []
|
||||
received_b = []
|
||||
|
||||
async def handler_a(task_data):
|
||||
received_a.append(task_data)
|
||||
|
||||
async def handler_b(task_data):
|
||||
received_b.append(task_data)
|
||||
|
||||
await self.registry.watch("greeter", handler_a)
|
||||
await self.registry.watch("greeter", handler_b)
|
||||
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
self.assertEqual(len(received_a), 1)
|
||||
self.assertEqual(len(received_b), 1)
|
||||
|
||||
async def test_watch_fires_immediately_if_already_registered(self):
|
||||
"""Watch handler fires immediately when the task is already registered."""
|
||||
data = TaskReadyData(task_name="greeter", runner="runner_a")
|
||||
await self.registry.register(data)
|
||||
|
||||
received = []
|
||||
|
||||
async def handler(task_data):
|
||||
received.append(task_data)
|
||||
|
||||
await self.registry.watch("greeter", handler)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertIs(received[0], data)
|
||||
|
||||
async def test_runner_name_property(self):
|
||||
"""runner_name returns the name passed at construction."""
|
||||
self.assertEqual(self.registry.runner_name, "runner_a")
|
||||
|
||||
async def test_multiple_remote_runners(self):
|
||||
"""Tasks from multiple remote runners are tracked separately."""
|
||||
data_b = TaskReadyData(task_name="task_b", runner="runner_b")
|
||||
data_c = TaskReadyData(task_name="task_c", runner="runner_c")
|
||||
await self.registry.register(data_b)
|
||||
await self.registry.register(data_c)
|
||||
|
||||
remote = self.registry.remote_tasks
|
||||
self.assertIn("task_b", remote)
|
||||
self.assertIn("task_c", remote)
|
||||
self.assertEqual(len(remote), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
202
tests/test_runner.py
Normal file
202
tests/test_runner.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.bus import (
|
||||
BusAddTaskMessage,
|
||||
BusCancelMessage,
|
||||
BusCancelTaskMessage,
|
||||
BusEndMessage,
|
||||
BusEndTaskMessage,
|
||||
)
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
||||
|
||||
class StubTask(BaseTask):
|
||||
"""BaseTask subclass that stops on end/cancel so the runner can exit."""
|
||||
|
||||
async def _handle_task_end(self, message):
|
||||
await super()._handle_task_end(message)
|
||||
self._finished_event.set()
|
||||
|
||||
async def _handle_task_cancel(self, message):
|
||||
await super()._handle_task_cancel(message)
|
||||
self._finished_event.set()
|
||||
|
||||
|
||||
class TestPipelineRunner(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_spawn_registers_task(self):
|
||||
"""spawn() registers the task by name (duplicate is silently skipped)."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
|
||||
await runner.spawn(task)
|
||||
|
||||
# Duplicate is silently skipped (logs error)
|
||||
await runner.spawn(StubTask("task_a"))
|
||||
|
||||
async def test_run_starts_bus_and_tasks(self):
|
||||
"""run() starts bus, starts all tasks, fires on_ready."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
await runner.spawn(task)
|
||||
|
||||
runner_started = asyncio.Event()
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
runner_started.set()
|
||||
# Immediately end to unblock run()
|
||||
await runner.end()
|
||||
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
|
||||
self.assertTrue(runner_started.is_set())
|
||||
|
||||
async def test_end_is_idempotent(self):
|
||||
"""end() is idempotent — subsequent calls are no-ops."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
await runner.spawn(task)
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
await runner.end(reason="first")
|
||||
await runner.end(reason="second") # should be no-op
|
||||
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
# If we got here without hanging, idempotency works
|
||||
|
||||
async def test_cancel_is_idempotent(self):
|
||||
"""cancel() is idempotent — subsequent calls are no-ops."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
await runner.spawn(task)
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
await runner.cancel(reason="first")
|
||||
await runner.cancel(reason="second") # should be no-op
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def test_end_sends_end_task_message_to_root_tasks_only(self):
|
||||
"""end() sends BusEndTaskMessage only to root tasks (no parent)."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
root = StubTask("root")
|
||||
child = StubTask("child")
|
||||
# Manually mark child as having root as parent
|
||||
child._parent = root.name
|
||||
await runner.spawn(root)
|
||||
await runner.spawn(child)
|
||||
|
||||
sent = []
|
||||
bus = runner.bus
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(message):
|
||||
sent.append(message)
|
||||
await original_send(message)
|
||||
|
||||
bus.send = capture_send
|
||||
|
||||
# Call end() directly — no need to run the full pipeline lifecycle
|
||||
await runner.end()
|
||||
|
||||
end_msgs = [m for m in sent if isinstance(m, BusEndTaskMessage)]
|
||||
targets = {m.target for m in end_msgs}
|
||||
self.assertIn("root", targets)
|
||||
self.assertNotIn("child", targets)
|
||||
|
||||
async def test_cancel_sends_cancel_task_message_to_root_tasks_only(self):
|
||||
"""cancel() sends BusCancelTaskMessage only to root tasks (no parent)."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
root = StubTask("root")
|
||||
child = StubTask("child")
|
||||
child._parent = root.name
|
||||
await runner.spawn(root)
|
||||
await runner.spawn(child)
|
||||
|
||||
sent = []
|
||||
bus = runner.bus
|
||||
original_send = bus.send
|
||||
|
||||
async def capture_send(message):
|
||||
sent.append(message)
|
||||
await original_send(message)
|
||||
|
||||
bus.send = capture_send
|
||||
|
||||
# Call cancel() directly — no need to run the full pipeline lifecycle
|
||||
await runner.cancel()
|
||||
|
||||
cancel_msgs = [m for m in sent if isinstance(m, BusCancelTaskMessage)]
|
||||
targets = {m.target for m in cancel_msgs}
|
||||
self.assertIn("root", targets)
|
||||
self.assertNotIn("child", targets)
|
||||
|
||||
async def test_bus_end_message_triggers_end(self):
|
||||
"""BusEndMessage on bus triggers runner.end()."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
await runner.spawn(task)
|
||||
|
||||
bus = runner.bus
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
# Simulate a task sending BusEndMessage
|
||||
await bus.send(BusEndMessage(source="task_a"))
|
||||
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
# If we got here, end was triggered by the bus message
|
||||
|
||||
async def test_bus_cancel_message_triggers_cancel(self):
|
||||
"""BusCancelMessage on bus triggers runner.cancel()."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task = StubTask("task_a")
|
||||
await runner.spawn(task)
|
||||
|
||||
bus = runner.bus
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
await bus.send(BusCancelMessage(source="task_a"))
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def test_bus_add_task_message_triggers_spawn(self):
|
||||
"""BusAddTaskMessage on bus triggers spawn()."""
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
task_a = StubTask("task_a")
|
||||
await runner.spawn(task_a)
|
||||
|
||||
task_b = StubTask("task_b")
|
||||
bus = runner.bus
|
||||
|
||||
@runner.event_handler("on_ready")
|
||||
async def on_ready(runner):
|
||||
await bus.send(BusAddTaskMessage(source="task_a", task=task_b))
|
||||
await asyncio.sleep(0.1)
|
||||
await runner.end()
|
||||
|
||||
await asyncio.wait_for(runner.run(), timeout=5.0)
|
||||
|
||||
# Verify task_b was added (duplicate is silently skipped)
|
||||
await runner.spawn(StubTask("task_b"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
246
tests/test_serializers.py
Normal file
246
tests/test_serializers.py
Normal file
@@ -0,0 +1,246 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.bus.messages import (
|
||||
BusActivateTaskMessage,
|
||||
BusCancelMessage,
|
||||
BusDataMessage,
|
||||
BusEndMessage,
|
||||
BusFrameMessage,
|
||||
BusJobRequestMessage,
|
||||
BusJobResponseMessage,
|
||||
BusMessage,
|
||||
)
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.pipeline.job_context import JobStatus
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
|
||||
|
||||
class _Address(BaseModel):
|
||||
city: str
|
||||
zip_code: str
|
||||
|
||||
|
||||
class _UserInfo(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
address: _Address | None = None
|
||||
|
||||
|
||||
class TestJSONMessageSerializer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
def test_round_trip_simple_message(self):
|
||||
"""BusMessage serializes and deserializes correctly."""
|
||||
msg = BusDataMessage(source="task_a", target="task_b")
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusMessage)
|
||||
self.assertEqual(restored.source, "task_a")
|
||||
self.assertEqual(restored.target, "task_b")
|
||||
|
||||
def test_round_trip_broadcast_message(self):
|
||||
"""Broadcast message (no target) round-trips."""
|
||||
msg = BusDataMessage(source="task_a")
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusMessage)
|
||||
self.assertEqual(restored.source, "task_a")
|
||||
self.assertIsNone(restored.target)
|
||||
|
||||
def test_round_trip_activate_message(self):
|
||||
"""BusActivateTaskMessage with args round-trips."""
|
||||
msg = BusActivateTaskMessage(
|
||||
source="parent",
|
||||
target="child",
|
||||
args={"messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusActivateTaskMessage)
|
||||
self.assertEqual(restored.source, "parent")
|
||||
self.assertEqual(restored.target, "child")
|
||||
self.assertEqual(restored.args["messages"][0]["content"], "hello")
|
||||
|
||||
def test_round_trip_end_message(self):
|
||||
"""BusEndMessage round-trips."""
|
||||
msg = BusEndMessage(source="task_a", reason="done")
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusEndMessage)
|
||||
self.assertEqual(restored.reason, "done")
|
||||
|
||||
def test_round_trip_cancel_message(self):
|
||||
"""BusCancelMessage round-trips."""
|
||||
msg = BusCancelMessage(source="task_a", reason="abort")
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusCancelMessage)
|
||||
self.assertEqual(restored.reason, "abort")
|
||||
|
||||
def test_round_trip_job_request(self):
|
||||
"""BusJobRequestMessage with payload round-trips."""
|
||||
msg = BusJobRequestMessage(
|
||||
source="parent",
|
||||
target="worker",
|
||||
job_id="t-123",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusJobRequestMessage)
|
||||
self.assertEqual(restored.job_id, "t-123")
|
||||
self.assertEqual(restored.payload, {"key": "value"})
|
||||
|
||||
def test_round_trip_job_response(self):
|
||||
"""BusJobResponseMessage round-trips."""
|
||||
msg = BusJobResponseMessage(
|
||||
source="worker",
|
||||
target="parent",
|
||||
job_id="t-123",
|
||||
status=JobStatus.COMPLETED,
|
||||
response={"result": 42},
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusJobResponseMessage)
|
||||
self.assertEqual(restored.job_id, "t-123")
|
||||
self.assertEqual(restored.response, {"result": 42})
|
||||
self.assertEqual(restored.status, JobStatus.COMPLETED)
|
||||
|
||||
def test_round_trip_frame_message(self):
|
||||
"""BusFrameMessage with TextFrame round-trips via adapter."""
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=TextFrame(text="hello world"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusFrameMessage)
|
||||
self.assertIsInstance(restored.frame, TextFrame)
|
||||
self.assertEqual(restored.frame.text, "hello world")
|
||||
self.assertEqual(restored.direction, FrameDirection.DOWNSTREAM)
|
||||
self.assertEqual(restored.source, "task_a")
|
||||
|
||||
def test_frame_message_upstream_direction(self):
|
||||
"""UPSTREAM direction preserved in round-trip."""
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=TextFrame(text="up"),
|
||||
direction=FrameDirection.UPSTREAM,
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertEqual(restored.direction, FrameDirection.UPSTREAM)
|
||||
|
||||
def test_unregistered_frame_warns_and_skips(self):
|
||||
"""Serializing a frame with no adapter warns and skips the field."""
|
||||
serializer = JSONMessageSerializer() # no adapters registered
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="task_a",
|
||||
frame=TextFrame(text="hello"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
# Should not raise — unserializable field is skipped with a warning
|
||||
data = serializer.serialize(msg)
|
||||
self.assertIsInstance(data, bytes)
|
||||
|
||||
def test_unknown_message_type_returns_none(self):
|
||||
"""Deserializing an unknown message type returns None."""
|
||||
bad_data = b'{"__type__":"bogus.BogusMessage","__data__":{"source":"a"}}'
|
||||
result = self.serializer.deserialize(bad_data)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_serialized_is_bytes(self):
|
||||
"""serialize() returns bytes."""
|
||||
msg = BusDataMessage(source="a")
|
||||
data = self.serializer.serialize(msg)
|
||||
self.assertIsInstance(data, bytes)
|
||||
|
||||
def test_adapter_mro_lookup(self):
|
||||
"""Adapter registered for a parent class handles subclasses."""
|
||||
|
||||
class CustomTextFrame(TextFrame):
|
||||
pass
|
||||
|
||||
msg = BusFrameMessage(
|
||||
source="a",
|
||||
frame=CustomTextFrame(text="sub"),
|
||||
direction=FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
# TextTypeAdapter is registered for TextFrame, should handle subclass
|
||||
data = self.serializer.serialize(msg)
|
||||
self.assertIsInstance(data, bytes)
|
||||
|
||||
def test_round_trip_pydantic_base_model(self):
|
||||
"""Pydantic BaseModel round-trips preserving the type."""
|
||||
msg = BusJobResponseMessage(
|
||||
source="worker",
|
||||
target="parent",
|
||||
job_id="t-456",
|
||||
status=JobStatus.COMPLETED,
|
||||
response={"user": _UserInfo(name="Alice", age=30)},
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusJobResponseMessage)
|
||||
user = restored.response["user"]
|
||||
self.assertIsInstance(user, _UserInfo)
|
||||
self.assertEqual(user.name, "Alice")
|
||||
self.assertEqual(user.age, 30)
|
||||
|
||||
def test_round_trip_nested_pydantic_base_model(self):
|
||||
"""Nested Pydantic BaseModels round-trip preserving types."""
|
||||
user = _UserInfo(name="Alice", age=30, address=_Address(city="NYC", zip_code="10001"))
|
||||
msg = BusJobResponseMessage(
|
||||
source="worker",
|
||||
target="parent",
|
||||
job_id="t-789",
|
||||
status=JobStatus.COMPLETED,
|
||||
response={"user": user},
|
||||
)
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertIsInstance(restored, BusJobResponseMessage)
|
||||
restored_user = restored.response["user"]
|
||||
self.assertIsInstance(restored_user, _UserInfo)
|
||||
self.assertIsInstance(restored_user.address, _Address)
|
||||
self.assertEqual(restored_user.address.city, "NYC")
|
||||
self.assertEqual(restored_user.address.zip_code, "10001")
|
||||
|
||||
def test_non_init_fields_preserved(self):
|
||||
"""Non-init dataclass fields survive round-trip via setattr."""
|
||||
msg = BusDataMessage(source="task_a", target="task_b")
|
||||
# name is a non-init field on DataFrame
|
||||
msg.name = "custom_name"
|
||||
|
||||
data = self.serializer.serialize(msg)
|
||||
restored = self.serializer.deserialize(data)
|
||||
|
||||
self.assertEqual(restored.name, "custom_name")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
318
tests/test_websocket_proxy.py
Normal file
318
tests/test_websocket_proxy.py
Normal file
@@ -0,0 +1,318 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pipecat.bus import (
|
||||
AsyncQueueBus,
|
||||
BusAddTaskMessage,
|
||||
BusDataMessage,
|
||||
)
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.registry import TaskRegistry
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||
|
||||
|
||||
async def create_test_bus():
|
||||
"""Create an AsyncQueueBus with a TaskManager for testing."""
|
||||
bus = AsyncQueueBus()
|
||||
tm = TaskManager()
|
||||
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||
await bus.setup(tm)
|
||||
return bus, tm
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
"""Fake WebSocket for testing the client proxy."""
|
||||
|
||||
def __init__(self):
|
||||
self._sent: list[bytes] = []
|
||||
self._receive_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
self.closed = False
|
||||
|
||||
async def send(self, data: bytes):
|
||||
self._sent.append(data)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return await asyncio.wait_for(self._receive_queue.get(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
raise StopAsyncIteration
|
||||
|
||||
def inject(self, data: bytes):
|
||||
"""Inject data as if received from the remote side."""
|
||||
self._receive_queue.put_nowait(data)
|
||||
|
||||
|
||||
class FakeStarletteWebSocket:
|
||||
"""Fake Starlette WebSocket for testing the server proxy."""
|
||||
|
||||
def __init__(self):
|
||||
self._sent: list[bytes] = []
|
||||
self._receive_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
self.closed = False
|
||||
|
||||
async def send_bytes(self, data: bytes):
|
||||
self._sent.append(data)
|
||||
|
||||
async def receive_bytes(self) -> bytes:
|
||||
return await self._receive_queue.get()
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
@property
|
||||
def client_state(self):
|
||||
return MagicMock(value=1) if not self.closed else MagicMock(value=3)
|
||||
|
||||
def inject(self, data: bytes):
|
||||
"""Inject data as if received from the remote client."""
|
||||
self._receive_queue.put_nowait(data)
|
||||
|
||||
|
||||
class TestWebSocketProxyClientTask(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.tm = await create_test_bus()
|
||||
self.registry = TaskRegistry(runner_name="test-runner")
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
async def _create_client(self, fake_ws):
|
||||
from pipecat.tasks.proxy.websocket.client import WebSocketProxyClientTask
|
||||
|
||||
task = WebSocketProxyClientTask(
|
||||
"proxy",
|
||||
url="ws://fake",
|
||||
remote_task_name="worker",
|
||||
local_task_name="voice",
|
||||
serializer=self.serializer,
|
||||
)
|
||||
task.attach(registry=self.registry, bus=self.bus)
|
||||
await task.setup(self.tm)
|
||||
task._ws = fake_ws
|
||||
return task
|
||||
|
||||
async def test_forwards_targeted_messages(self):
|
||||
"""Messages targeted at the remote task are forwarded."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="voice", target="worker")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 1)
|
||||
restored = self.serializer.deserialize(fake_ws._sent[0])
|
||||
self.assertEqual(restored.source, "voice")
|
||||
self.assertEqual(restored.target, "worker")
|
||||
|
||||
async def test_skips_messages_for_other_tasks(self):
|
||||
"""Messages targeted at other tasks are not forwarded."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="voice", target="other_task")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 0)
|
||||
|
||||
async def test_skips_broadcast_messages(self):
|
||||
"""Broadcast messages (target=None) are not forwarded."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="voice")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 0)
|
||||
|
||||
async def test_skips_local_messages(self):
|
||||
"""BusLocalMessage messages are not forwarded."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
stub = MagicMock(spec=BaseTask)
|
||||
stub.name = "child"
|
||||
msg = BusAddTaskMessage(source="parent", target="worker", task=stub)
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 0)
|
||||
|
||||
async def test_accepts_inbound_for_local_task(self):
|
||||
"""Inbound messages targeted at the local task are accepted."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
sent_to_bus = []
|
||||
original_send = task.send_message
|
||||
|
||||
async def capture_send(message):
|
||||
sent_to_bus.append(message)
|
||||
await original_send(message)
|
||||
|
||||
task.send_message = capture_send
|
||||
|
||||
msg = BusDataMessage(source="worker", target="voice")
|
||||
fake_ws.inject(self.serializer.serialize(msg))
|
||||
|
||||
receive_task = asyncio.create_task(task._receive_loop())
|
||||
await asyncio.sleep(0.1)
|
||||
receive_task.cancel()
|
||||
try:
|
||||
await receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(sent_to_bus), 1)
|
||||
self.assertEqual(sent_to_bus[0].source, "worker")
|
||||
self.assertEqual(sent_to_bus[0].target, "voice")
|
||||
|
||||
async def test_drops_inbound_for_other_tasks(self):
|
||||
"""Inbound messages targeted at other tasks are dropped."""
|
||||
fake_ws = FakeWebSocket()
|
||||
task = await self._create_client(fake_ws)
|
||||
|
||||
sent_to_bus = []
|
||||
original_send = task.send_message
|
||||
|
||||
async def capture_send(message):
|
||||
sent_to_bus.append(message)
|
||||
await original_send(message)
|
||||
|
||||
task.send_message = capture_send
|
||||
|
||||
msg = BusDataMessage(source="worker", target="other_task")
|
||||
fake_ws.inject(self.serializer.serialize(msg))
|
||||
|
||||
receive_task = asyncio.create_task(task._receive_loop())
|
||||
await asyncio.sleep(0.1)
|
||||
receive_task.cancel()
|
||||
try:
|
||||
await receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(sent_to_bus), 0)
|
||||
|
||||
|
||||
class TestWebSocketProxyServerTask(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.bus, self.tm = await create_test_bus()
|
||||
self.registry = TaskRegistry(runner_name="test-runner")
|
||||
self.serializer = JSONMessageSerializer()
|
||||
|
||||
async def _create_server(self, fake_ws):
|
||||
from pipecat.tasks.proxy.websocket.server import WebSocketProxyServerTask
|
||||
|
||||
task = WebSocketProxyServerTask(
|
||||
"gateway",
|
||||
websocket=fake_ws,
|
||||
task_name="worker",
|
||||
remote_task_name="voice",
|
||||
serializer=self.serializer,
|
||||
)
|
||||
task.attach(registry=self.registry, bus=self.bus)
|
||||
await task.setup(self.tm)
|
||||
return task
|
||||
|
||||
async def test_forwards_messages_from_local_task(self):
|
||||
"""Messages from the local task targeted at the remote task are forwarded."""
|
||||
fake_ws = FakeStarletteWebSocket()
|
||||
task = await self._create_server(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="worker", target="voice")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 1)
|
||||
restored = self.serializer.deserialize(fake_ws._sent[0])
|
||||
self.assertEqual(restored.source, "worker")
|
||||
self.assertEqual(restored.target, "voice")
|
||||
|
||||
async def test_skips_messages_from_other_tasks(self):
|
||||
"""Messages from other tasks are not forwarded."""
|
||||
fake_ws = FakeStarletteWebSocket()
|
||||
task = await self._create_server(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="other_task", target="voice")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 0)
|
||||
|
||||
async def test_skips_messages_to_other_targets(self):
|
||||
"""Messages from the local task to other targets are not forwarded."""
|
||||
fake_ws = FakeStarletteWebSocket()
|
||||
task = await self._create_server(fake_ws)
|
||||
|
||||
msg = BusDataMessage(source="worker", target="other_task")
|
||||
await task.on_bus_message(msg)
|
||||
|
||||
self.assertEqual(len(fake_ws._sent), 0)
|
||||
|
||||
async def test_accepts_inbound_for_local_task(self):
|
||||
"""Inbound messages targeted at the local task are accepted."""
|
||||
fake_ws = FakeStarletteWebSocket()
|
||||
task = await self._create_server(fake_ws)
|
||||
|
||||
sent_to_bus = []
|
||||
original_send = task.send_message
|
||||
|
||||
async def capture_send(message):
|
||||
sent_to_bus.append(message)
|
||||
await original_send(message)
|
||||
|
||||
task.send_message = capture_send
|
||||
|
||||
msg = BusDataMessage(source="voice", target="worker")
|
||||
fake_ws.inject(self.serializer.serialize(msg))
|
||||
|
||||
receive_task = asyncio.create_task(task._receive_loop())
|
||||
await asyncio.sleep(0.1)
|
||||
receive_task.cancel()
|
||||
try:
|
||||
await receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(sent_to_bus), 1)
|
||||
self.assertEqual(sent_to_bus[0].target, "worker")
|
||||
|
||||
async def test_drops_inbound_for_other_tasks(self):
|
||||
"""Inbound messages targeted at other tasks are dropped."""
|
||||
fake_ws = FakeStarletteWebSocket()
|
||||
task = await self._create_server(fake_ws)
|
||||
|
||||
sent_to_bus = []
|
||||
original_send = task.send_message
|
||||
|
||||
async def capture_send(message):
|
||||
sent_to_bus.append(message)
|
||||
await original_send(message)
|
||||
|
||||
task.send_message = capture_send
|
||||
|
||||
msg = BusDataMessage(source="voice", target="other_task")
|
||||
fake_ws.inject(self.serializer.serialize(msg))
|
||||
|
||||
receive_task = asyncio.create_task(task._receive_loop())
|
||||
await asyncio.sleep(0.1)
|
||||
receive_task.cancel()
|
||||
try:
|
||||
await receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(sent_to_bus), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user