Hace tres semanas estaba depurando un error que solo aparecía en producción. Un endpoint de FastAPI que devolvía datos de paginación inconsistentes cuando el usuario ordenaba por múltiples columnas simultáneamente. Nuestro ORM (SQLAlchemy 2.0.36) generaba queries distintas dependiendo de si el resultado del caché era un objeto nuevo o uno hidratado. Clásico.
Lo que me llamó la atención no fue el bug en sí — ese tipo de problema lo he visto mil veces — sino la diferencia brutal entre cómo me ayudaron los distintos asistentes de IA mientras lo depuraba. Uno me dio exactamente el código que necesitaba en el segundo intento. Otro estuvo dando vueltas en círculos durante diez minutos. Y eso me hizo pensar: ¿cuánto de lo que creemos sobre estos asistentes viene de demos pulidas versus uso real?
Así que pasé las siguientes tres semanas haciendo pruebas sistemáticas. Trabajo en un equipo de cuatro personas construyendo una plataforma de análisis B2B — stack principal: FastAPI + React + PostgreSQL, deployado en AWS. Usé tareas de mi backlog normal, no ejercicios inventados.
Los candidatos: GitHub Copilot (con el backend de Claude Sonnet 4.5 habilitado desde el panel de configuración), Cursor 0.47, Claude Code CLI (también Sonnet 4.5), y Windsurf 1.9.2.
Cómo diseñé las pruebas para que importaran
Lo primero que descarté fueron los benchmarks de autocompletado de funciones aisladas. HumanEval y SWE-bench son útiles para comparar modelos base, pero no me dicen si un asistente me va a ayudar un martes por la tarde con un servicio que tiene 4.000 líneas de historia y dependencias raras.
Definí cuatro categorías de tareas:
- Comprensión de contexto amplio — darle un módulo de 800+ líneas y pedirle que añada una feature que respete los patrones existentes
- Depuración con información parcial — pegarle un stack trace y el fragmento de código relevante, sin el contexto completo del repo
- Refactoring con restricciones — “cambia esto sin romper la API pública, y sin cambiar los tests existentes”
- Generación de tests — escribir tests de integración para código legacy con acoplamiento fuerte
Tres tareas reales distintas por categoría. Evaluación subjetiva (lo sé, lo sé) en tres dimensiones: si el primer intento era usable directamente, cuántas iteraciones necesité, y si introdujo bugs que no estaban antes. No medí velocidad de tokens por segundo. No me importa que sea rápido si el output está mal.
Comprensión de contexto: aquí es donde se separan los buenos de los mediocres
La prueba que más diferencia marcó fue esta: tengo un módulo de exportación de datos (data_export/pipeline.py, ~1.100 líneas) que usa un patrón de plugins registrados via decoradores. Le pedí a cada asistente que añadiera soporte para un nuevo formato de exportación siguiendo exactamente el mismo patrón.
# Patrón existente que el asistente necesitaba reconocer y replicar
@export_registry.register("csv")
class CSVExporter(BaseExporter):
"""
Exporta datos en formato CSV con soporte para encodings custom.
El registry inyecta config via __init_subclass__ — no tocar ese flujo.
"""
def export(self, queryset: QuerySet, options: ExportOptions) -> BytesIO:
# La lógica de chunking está en BaseExporter.stream_chunks()
# Los exporters solo deben implementar _serialize_chunk()
buffer = BytesIO()
for chunk in self.stream_chunks(queryset, options.chunk_size):
buffer.write(self._serialize_chunk(chunk, options))
return buffer
def _serialize_chunk(self, data: list[dict], options: ExportOptions) -> bytes:
# ... implementación real omitida por brevedad
Claude Code (CLI): En el segundo intento — el primero le faltó el decorador de registro — produjo un exporter de Parquet que usaba stream_chunks() correctamente y respetó la convención de __init_subclass__. Tuve que cambiar dos líneas. Tiempo total: unos 6 minutos.
Cursor: El resultado fue funcionalmente correcto pero ignoró el patrón de stream_chunks() y escribió su propio loop de chunking desde cero. Funciona, pero ahora tengo deuda técnica nueva porque si cambio BaseExporter, ese exporter no hereda el cambio.
Copilot: Similar a Cursor. Entiende el decorador pero no entiende por qué existe _serialize_chunk() como método separado. El primer intento puso toda la lógica en export().
Windsurf: Sorpresa positiva — captó el patrón mejor de lo que esperaba. El resultado estaba al mismo nivel que Claude Code, aunque le tomó más iteraciones llegar ahí (cuatro intercambios vs. dos).
Para código con patrones internos no obvios, la ventana de contexto y cómo el modelo la procesa importa más que la velocidad de autocompletado. Si tu codebase tiene convenciones propias — y después de dos años cualquier codebase las tiene — vas a notar la diferencia.
Depuración con información parcial: el escenario del stack trace a las 11pm
Este es el caso de uso que más me aparece en la práctica. Algo explota en staging, tengo el traceback y el código relevante, no tengo tiempo para dar contexto completo.
Usé tres bugs reales del último mes. El más interesante para ilustrar: un RecursionError que ocurría en la serialización de Pydantic v2 cuando un modelo tenía referencias circulares a través de un campo Optional con model_rebuild().
Look, aquí cometí un error que me costó tiempo: asumí que cualquier asistente entendería la diferencia entre Pydantic v1 y v2 sin que yo lo especificara. Copilot me dio tres soluciones distintas que funcionarían en v1 pero que en v2 están deprecated o directamente eliminadas — validator en lugar de field_validator, __fields__ en lugar de model_fields. Errores que indican que el modelo está mezclando conocimiento de ambas versiones. Si no conoces bien Pydantic puedes no darte cuenta y gastar media hora implementando algo que no va a funcionar. Desde entonces siempre especifico la versión exacta en mi prompt inicial. Debería haberlo hecho desde el principio.
Claude Code fue el que mejor navegó este tipo de ambigüedad — en parte, creo, porque al ser CLI con acceso al repo completo puede leer el pyproject.toml y verificar qué versión tienes instalada antes de responder. Windsurf tiene algo similar. Copilot en modo chat no hace eso por defecto.
Versiones exactas en el contexto, siempre. Y si el asistente tiene acceso a tus archivos de configuración del proyecto, aprovéchalo — es información que cambia completamente la calidad de la respuesta.
Refactoring con restricciones: donde la mayoría falla
Esta fue la categoría más frustrante de probar porque los asistentes tienden a ser demasiado ambiciosos. Les pides que refactoricen una función y te reescriben el módulo entero.
La tarea: teníamos un servicio de notificaciones (notifications/dispatcher.py) con una función de 120 líneas que hacía demasiadas cosas. Quería extraer la lógica de throttling a una clase separada sin cambiar la firma pública de la función ni los tests existentes — 27 tests de integración que tardaban 40 segundos en correr y que no quería tocar.
# La firma que NO podía cambiar:
async def dispatch_notification(
user_id: int,
event: NotificationEvent,
channels: list[NotificationChannel],
*,
priority: Priority = Priority.NORMAL,
metadata: dict | None = None
) -> DispatchResult:
...
Copilot y Cursor — con distintos grados de severidad — ambos sugirieron cambios que habrían roto la API. Cursor propuso convertir channels de list a *args, lo cual parece menor pero rompe cualquier código que llame a la función con una lista ya construida. Copilot añadió un parámetro nuevo sin default, lo que obviamente rompe todo.
Claude Code respetó la restricción. Windsurf también la respetó, aunque su implementación del throttler tenía un bug sutil: usaba el user_id como única clave de throttle ignorando el channel, así que si un usuario recibía muchas notificaciones por email, también quedaba throttled para SMS. Tomó un intercambio adicional para corregirlo.
Hay algo que no esperaba: cuando le dije explícitamente a Cursor “no cambies la firma pública de dispatch_notification ni su comportamiento observable”, mejoró significativamente. La precisión de la instrucción importa más de lo que pensaba. No es que Cursor no pueda hacer refactoring correcto — es que por defecto asume que tienes permiso de cambiar todo si no dices lo contrario.
Generación de tests: el caso de uso más variable
La varianza aquí es enorme dependiendo del tipo de código que estés testeando. El patrón consistente: todos los asistentes son buenos generando tests para código nuevo y bien estructurado, todos son mediocres con código legacy y dependencias globales. Tiene sentido — si el código es difícil de testear, también es difícil de razonar sobre él desde fuera.
Lo que diferencia a los asistentes es qué hacen cuando el código legacy es problemático: ¿te lo dicen, o simplemente generan tests que parecen buenos pero que en realidad no testean lo que crees?
Claude Code fue el único que en dos ocasiones distintas me dijo algo del estilo de “esta función tiene un side effect en un singleton global, los tests que genere van a ser frágiles si no mocks eso primero — ¿quieres que lo haga?”. Eso es útil. El resto simplemente generó los tests y me los presentó como si fueran correctos. Mi muestra es pequeña, pero fue suficientemente consistente como para notarlo.
Mi recomendación real — sin rodeos
Si trabajas principalmente en el IDE y tu codebase es de tamaño medio (< 100k líneas): Cursor sigue siendo la opción más integrada y la experiencia general es mejor. El autocompletado en el editor es más rápido y natural que las alternativas. Pero cambia el modelo backend a Claude Sonnet 4.5 — la diferencia en calidad de output justifica el costo adicional y la latencia es aceptable.
Si haces mucho trabajo de arquitectura, refactoring grande, o depuración de bugs difíciles: Claude Code CLI. La capacidad de leer el repo completo, ejecutar comandos y mantener contexto a través de sesiones largas le da una ventaja real en tareas complejas. No es tan cómodo como un IDE integrado — la curva de adaptación existe — pero para trabajo de alta complejidad los resultados son notablemente mejores.
Copilot: Honestamente, si ya tienes la suscripción de GitHub y tu caso de uso principal es autocompletado rápido y sugerencias inline, funciona bien. Para razonamiento complejo o comprensión de código con patrones no estándar, quedó por detrás consistentemente. La integración del backend de Claude en Copilot es relativamente reciente — quizás mejore.
Windsurf: La sorpresa positiva del grupo. Para equipos que no quieren pagar por Cursor Pro y quieren algo más capaz que el Copilot estándar, es una opción seria. El modelo Cascade que usan para el contexto de repo funciona mejor de lo que esperaba dado el precio.
Una cosa que no cambiaría independientemente del asistente: aprende a escribir buenos prompts para tu contexto específico. La diferencia entre “refactoriza esto” y “refactoriza la función X para extraer la lógica de Y a una clase separada, sin cambiar la firma pública, manteniendo compatibilidad con los tests en test_dispatcher.py” es la diferencia entre un resultado inútil y uno que puedes usar directamente. El asistente es bueno. No es adivino.
Estos resultados reflejan mi stack (FastAPI/Python/React), mi estilo de trabajo, y las tareas que tengo en mi backlog. Si trabajas en Rust o en un monorepo de Java de 2 millones de líneas, probablemente el ranking cambiaría. Pero la metodología — pruébalo con tu código real, no con demos — aplica igual.