#
# 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.job_context import (
    JobError,
    JobEvent,
    JobGroup,
    JobGroupError,
    JobGroupEvent,
    JobGroupParams,
    JobParams,
    JobStatus,
)
from pipecat.registry import WorkerRegistry
from pipecat.registry.types import WorkerReadyData
from pipecat.utils.asyncio.task_manager import TaskManager
from pipecat.workers.base_worker import BaseWorker
from pipecat.workers.runner import WorkerRunner


class StubTask(BaseWorker):
    pass


class JobWorkerTask(BaseWorker):
    """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(BaseWorker):
    """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(BaseWorker):
    """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(BaseWorker):
    """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(BaseWorker):
    """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()
    await bus.setup(tm)
    await bus.start()
    registry = WorkerRegistry(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."""
    await task.attach(
        registry=registry, bus=bus, worker_runner=WorkerRunner(bus=bus, handle_sigint=False)
    )
    await task.setup(bus.task_manager)
    await bus.subscribe(task)
    await registry.register(WorkerReadyData(worker_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", params=JobGroupParams(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", params=JobGroupParams(payload={"data": 1})):
            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", params=JobGroupParams(timeout=0.05)):
                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", params=JobGroupParams(timeout=0.05)):
                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"):
                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"):
                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"):
            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", params=JobGroupParams(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].worker_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].worker_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"):
                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 TestRequestCancelJobGroup(unittest.IsolatedAsyncioTestCase):
    """External cancellation requests, which ``cancellable`` governs."""

    async def _worker(self):
        bus, tm, registry = await create_test_env()
        parent = BaseWorker("parent")
        await setup_task(bus, registry, parent)
        return parent

    async def test_cancels_a_cancellable_group(self):
        parent = await self._worker()
        parent._job_groups["j1"] = JobGroup(job_id="j1", worker_names=["w1"], cancellable=True)

        self.assertTrue(await parent.request_cancel_job_group("j1", reason="user"))
        self.assertNotIn("j1", parent._job_groups)

    async def test_refuses_a_non_cancellable_group(self):
        parent = await self._worker()
        parent._job_groups["j1"] = JobGroup(job_id="j1", worker_names=["w1"], cancellable=False)

        self.assertFalse(await parent.request_cancel_job_group("j1"))
        self.assertIn("j1", parent._job_groups)

    async def test_refuses_an_unknown_group(self):
        parent = await self._worker()

        self.assertFalse(await parent.request_cancel_job_group("nope"))

    async def test_worker_initiated_cancel_ignores_cancellable(self):
        """Shutdown, timeout and cancel_on_error go straight to cancel_job_group."""
        parent = await self._worker()
        parent._job_groups["j1"] = JobGroup(job_id="j1", worker_names=["w1"], cancellable=False)

        await parent.cancel_job_group("j1", reason="timeout")

        self.assertNotIn("j1", parent._job_groups)


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", params=JobParams(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"):
            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"):
                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"):
                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"):
                # 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"):
                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", params=JobParams(payload={"job": 1})))
            job_ids.append(await parent.request_job("w2", params=JobParams(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", params=JobParams(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()
