From ab289051e32b5d699273c48fae6880e139348b62 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Fri, 31 Jul 2026 20:35:17 +0530 Subject: [PATCH] fix(crew): send AgentReady before starting nodes so a greeting in start() works CrewSession.start() started nodes and only then sent SDKAgentReadyEvent. A node whose start() emits a speak (e.g. a greeting) had that event flushed to the platform before Ready; the platform's connect handshake then sees a non-Ready first frame, never spawns its receive loop, and the whole call goes silent. Send Ready first, then start nodes. Incoming events still buffer on the websocket until our receive loop starts (after nodes), so nothing is lost. Adds a regression test asserting Ready is sent before any node starts. --- src/smallestai/atoms/crew/session.py | 9 +++- .../test_crew_session_ready_ordering.py | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 tests/custom/test_crew_session_ready_ordering.py 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()