diff --git a/gsd-openclaw/src/gsd_openclaw/orchestrator.py b/gsd-openclaw/src/gsd_openclaw/orchestrator.py index 3fc2406..5043d83 100644 --- a/gsd-openclaw/src/gsd_openclaw/orchestrator.py +++ b/gsd-openclaw/src/gsd_openclaw/orchestrator.py @@ -83,6 +83,17 @@ class Orchestrator: self.user_context_managers[user_id] = UserContextManager(user_id, self._users_root) return self.user_context_managers[user_id] + def _is_long_command(self, command: str) -> bool: + """Check if command requires no timeout. + + Args: + command: The command name (e.g., 'execute-phase', 'status'). + + Returns: + True if command is in LONG_COMMANDS, False otherwise. + """ + return command in self.LONG_COMMANDS + def _handle_new_project(self, user_id: str, project_name: str) -> Dict[str, Any]: """Handle /gsd:new-project command. @@ -273,8 +284,11 @@ class Orchestrator: if args: full_command = f"{full_command} {args}" - # Execute command - result = self.executor.execute(session, full_command) + # Determine timeout based on command type + timeout = None if self._is_long_command(command) else self.executor.DEFAULT_TIMEOUT + + # Execute command with adaptive timeout + result = self.executor.execute(session, full_command, timeout=timeout) # Truncate output if needed output = result.output diff --git a/gsd-openclaw/tests/test_orchestrator.py b/gsd-openclaw/tests/test_orchestrator.py index 71ab572..4433a98 100644 --- a/gsd-openclaw/tests/test_orchestrator.py +++ b/gsd-openclaw/tests/test_orchestrator.py @@ -848,3 +848,160 @@ class TestSpecialCommands: # Verify get_session was called with the project from user context orch.tmux_manager.get_session.assert_called_with("context-project") + + +class TestAdaptiveTimeout: + """Tests for adaptive timeout based on command type.""" + + @pytest.fixture + def orchestrator_with_mocks(self): + """Create orchestrator with mocked dependencies for timeout tests.""" + from gsd_openclaw.orchestrator import Orchestrator + + mock_tmux_manager = MagicMock() + mock_formatter = MagicMock() + mock_executor = MagicMock() + mock_executor.DEFAULT_TIMEOUT = 60.0 # Set real timeout value on mock + + orchestrator = Orchestrator( + tmux_manager=mock_tmux_manager, + formatter=mock_formatter + ) + orchestrator.executor = mock_executor + + return orchestrator + + def test_orchestrator_has_long_commands_set(self, orchestrator_with_mocks): + """Orchestrator should have LONG_COMMANDS set defined.""" + orch = orchestrator_with_mocks + + assert hasattr(orch, 'LONG_COMMANDS') + assert 'execute-phase' in orch.LONG_COMMANDS + assert 'plan-phase' in orch.LONG_COMMANDS + assert 'discuss-phase' in orch.LONG_COMMANDS + assert 'new-project' in orch.LONG_COMMANDS + + def test_is_long_command_returns_true_for_long_commands(self, orchestrator_with_mocks): + """_is_long_command should return True for commands in LONG_COMMANDS.""" + orch = orchestrator_with_mocks + + assert orch._is_long_command('execute-phase') is True + assert orch._is_long_command('plan-phase') is True + assert orch._is_long_command('discuss-phase') is True + assert orch._is_long_command('new-project') is True + + def test_is_long_command_returns_false_for_regular_commands(self, orchestrator_with_mocks): + """_is_long_command should return False for commands not in LONG_COMMANDS.""" + orch = orchestrator_with_mocks + + assert orch._is_long_command('status') is False + assert orch._is_long_command('list') is False + assert orch._is_long_command('help') is False + assert orch._is_long_command('use') is False + + def test_execute_called_with_none_timeout_for_long_commands(self, orchestrator_with_mocks): + """execute() should be called with timeout=None for long commands.""" + from gsd_openclaw.tmux_session import TmuxSession + + orch = orchestrator_with_mocks + + mock_session = MagicMock(spec=TmuxSession) + mock_session.session_id = "test-123" + mock_session.tmux_session = MagicMock() + mock_session.tmux_session.active_pane = MagicMock() + orch.tmux_manager.get_session.return_value = mock_session + + orch.executor.execute.return_value = CommandResult( + output="Executing phase...", + completed=True, + has_error=False, + error_type=None + ) + orch.formatter.format_success.return_value = {"card": "success"} + + # Call a long command + result = orch.handle_message( + user_id="user1", + message="/gsd:execute-phase 01-core", + channel="test-channel", + context={"project": "test-project"} + ) + + # Verify execute was called with timeout=None + call_args = orch.executor.execute.call_args + assert call_args[1].get('timeout') is None + + def test_execute_called_with_default_timeout_for_regular_commands(self, orchestrator_with_mocks): + """execute() should be called with DEFAULT_TIMEOUT for regular commands.""" + from gsd_openclaw.tmux_session import TmuxSession + + orch = orchestrator_with_mocks + + mock_session = MagicMock(spec=TmuxSession) + mock_session.session_id = "test-123" + mock_session.tmux_session = MagicMock() + mock_session.tmux_session.active_pane = MagicMock() + orch.tmux_manager.get_session.return_value = mock_session + + orch.executor.execute.return_value = CommandResult( + output="Status: OK", + completed=True, + has_error=False, + error_type=None + ) + orch.formatter.format_success.return_value = {"card": "success"} + + # Call a regular command + result = orch.handle_message( + user_id="user1", + message="/gsd:status", + channel="test-channel", + context={"project": "test-project"} + ) + + # Verify execute was called with DEFAULT_TIMEOUT (60.0) + call_args = orch.executor.execute.call_args + assert call_args[1].get('timeout') == 60.0 + + def test_handle_message_passes_correct_timeout_based_on_command_type(self, orchestrator_with_mocks): + """handle_message should pass correct timeout based on command type.""" + from gsd_openclaw.tmux_session import TmuxSession + + orch = orchestrator_with_mocks + + mock_session = MagicMock(spec=TmuxSession) + mock_session.session_id = "test-123" + mock_session.tmux_session = MagicMock() + mock_session.tmux_session.active_pane = MagicMock() + orch.tmux_manager.get_session.return_value = mock_session + + orch.executor.execute.return_value = CommandResult( + output="Output", + completed=True, + has_error=False, + error_type=None + ) + orch.formatter.format_success.return_value = {"card": "success"} + + # Test plan-phase (long command) + orch.handle_message( + user_id="user1", + message="/gsd:plan-phase feature-x", + channel="test-channel", + context={"project": "test-project"} + ) + call_args = orch.executor.execute.call_args + assert call_args[1].get('timeout') is None + + # Reset mock + orch.executor.execute.reset_mock() + + # Test discuss-phase (long command) + orch.handle_message( + user_id="user1", + message="/gsd:discuss-phase about architecture", + channel="test-channel", + context={"project": "test-project"} + ) + call_args = orch.executor.execute.call_args + assert call_args[1].get('timeout') is None