diff --git a/src/smallestai/atoms/crew/session.py b/src/smallestai/atoms/crew/session.py index f580b759..f997a153 100644 --- a/src/smallestai/atoms/crew/session.py +++ b/src/smallestai/atoms/crew/session.py @@ -220,8 +220,15 @@ async def start(self) -> None: self._running = True - await self._start_nodes(self._init_event, self.task_manager) + # Send Ready BEFORE starting nodes. A node's start() may emit a speak + # event (e.g. a greeting); if that reaches the platform before Ready, the + # platform's connect handshake sees a non-Ready first frame and never + # spawns its receive loop, silencing the whole call. Ready-first keeps any + # early speak legal. Incoming events still buffer on the websocket until + # our own receive loop starts below (after nodes are up), so nothing is + # lost by ordering Ready first. await self.send_to_websocket(SDKAgentReadyEvent()) + await self._start_nodes(self._init_event, self.task_manager) self._receive_loop_task = self.task_manager.create_task( self._receive_loop(), name="receive_loop" diff --git a/tests/custom/test_crew_session_ready_ordering.py b/tests/custom/test_crew_session_ready_ordering.py new file mode 100644 index 00000000..bcb5c25e --- /dev/null +++ b/tests/custom/test_crew_session_ready_ordering.py @@ -0,0 +1,54 @@ +"""Regression test: CrewSession.start() must send SDKAgentReadyEvent BEFORE +starting nodes, so a node's start() that emits a speak (e.g. a greeting) doesn't +reach the platform before Ready and poison the connect handshake.""" +import unittest +from unittest import mock + +from smallestai.atoms.crew.session import CrewSession +from smallestai.atoms.crew.nodes import OutputCrewNode + + +class _OrderNode(OutputCrewNode): + def __init__(self, order): + super().__init__(name="rec") + self._order = order + + async def start(self, init_event, task_manager): + await super().start(init_event, task_manager) + self._order.append("node_started") + + async def generate_response(self): + if False: + yield "" # never runs; satisfies the abstract async-generator + + +class ReadyBeforeNodesTest(unittest.IsolatedAsyncioTestCase): + async def test_ready_sent_before_nodes_start(self): + order = [] + session = CrewSession( + websocket=mock.AsyncMock(), session_id="t", setup_handler=None + ) + session._init_event = mock.MagicMock() + session.task_manager = mock.MagicMock() + session.task_manager.create_task = mock.MagicMock() # don't run receive loop + session._receive_loop = mock.MagicMock() # avoid unawaited-coroutine warning + + async def _record_ready(event): + order.append("ready_sent") + + session.send_to_websocket = _record_ready + session.add_node(_OrderNode(order)) + + await session.start() + + self.assertIn("ready_sent", order) + self.assertIn("node_started", order) + self.assertLess( + order.index("ready_sent"), + order.index("node_started"), + f"Ready must be sent before nodes start; got order={order}", + ) + + +if __name__ == "__main__": + unittest.main()