Open source · ROSH™ Company Labs

cut-at-k ce qu'un flux coupé dit à votre client

cut-at-k rejoue un flux entier puis à chaque coupure, et vérifie si votre client admet avoir été coupé. MIT, zéro dépendance, 21 tests. ROSH™ Company Labs.

Un flux qui s’arrête tôt n’a rien d’un cas rare

Une connexion tombe. Un proxy expire. Le producteur meurt au milieu d’une phrase, ou un lecteur appuie sur stop. Le flux se termine là où il se termine, et la bibliothèque cliente doit bien dire quelque chose à son appelant sur ce qui vient de se passer. Tester ce chemin suppose de choisir un point de coupure.

cut-at-k supprime ce choix. Il prend un flux, le fait passer dans votre client une fois entier puis une fois pour chaque préfixe, et pose deux questions à chaque coupure :

C’est à la seconde question que les choses se gâtent d’habitude, et elles se gâtent en silence : la promesse se résout, l’objet de résultat a l’air ordinaire, et rien dessus ne dit que la réponse n’est qu’une demi-réponse.

Il ne connaît rien à votre protocole

Vous fournissez les événements et une fonction de rejeu. Il fournit la boucle et la comparaison. Des server-sent events, des trames WebSocket, une fixture JSON Lines, un tableau construit à la main — il ne regarde jamais à l’intérieur d’un événement.

npm install cut-at-k
import { severAtEveryPoint } from 'cut-at-k';

await severAtEveryPoint({
  events,                                   // the stream, as an array
  label: 'checkout-run',
  replay: (prefix) => runMyClient(prefix)   // called whole, then per prefix
});

Trois points d’entrée portent le travail : severAtEveryPoint lance les rejeux, summarise les réduit en un rapport, format l’imprime. Zéro dépendance, dix fichiers, MIT.

Il refuse de crier au loup

Trois choses se ressemblent trait pour trait dans les chiffres bruts, et une seule est un vrai constat.

Donc summarise ne signale strictement aucun défaut tant que vous ne lui passez pas un prédicat lostContent. C’est vous qui décidez ce qui compte comme contenu dans votre protocole ; l’outil ne devinera pas à votre place. Les flux écartés comme invalides à dessein reviennent sous forme de décompte plutôt que de disparaître discrètement : vous voyez toujours quelle part d’un corpus a été mise de côté.

Cette leçon a été apprise en se trompant. La première version de summarise ne faisait aucune de ces distinctions et déclarait 154 coupures problématiques sur un corpus où il y en avait 54. Le README garde la phrase qui en est sortie : « Un outil qui crie au loup est pire que pas d’outil du tout. »

Passé sur AG-UI : 227 coupures, 54 qui ont perdu quelque chose en silence

AG-UI livre 68 fixtures de conformité écrites par les auteurs mêmes du protocole. Deux ne font qu’un seul événement et n’offrent aucun point de coupure : 66 ont donc été rejouées — chacune à travers un vrai HttpAgent, sur du vrai HTTP et du vrai SSE avec @ag-ui/client 1.0.0, entière puis coupée à chaque frontière d’événement.

48 streams, 227 cuts, 18 streams excluded as invalid on purpose
4 cuts threw during replay

reported the same as the whole run    : 154
  of which something was actually lost : 54
  of those, no terminal event either   : 52
spread across                          : 34 streams

Le plus petit cas est le plus clair. Prenez la fixture nommée conformant-run-is-quiet et coupez-la après son quatrième événement. L’appel tronqué et l’appel complet se résolvent tous les deux. Aucun des deux ne rejette. RunAgentResult ne porte aucun champ disant lequel a été interrompu. Le nombre de messages vaut 1 dans les deux cas. Un appelant qui vérifie si l’exécution a réussi, ou combien de messages sont revenus, s’entend dire exactement la même chose par une exécution allée à son terme et par une exécution arrêtée au milieu d’une phrase.

Quatre de ces 227 coupures ont levé une erreur pendant le rejeu. Elles restent dans le total et sont mises de côté avant la comparaison, car une coupure qui fait planter le client est un constat différent d’une coupure qui a rapporté la mauvaise chose.

Ce que cette page ne prétend pas

Un abonné peut détecter cela dès aujourd’hui. onRunFinishedEvent se déclenche sur l’exécution complète et pas sur l’exécution tronquée, tandis que onRunFinalized se déclenche sur les deux — la différence est donc observable si vous écoutez ce canal-là. L’affirmation faite ici est plus étroite : l’appel attendu, lui, ne la fait pas remonter. Il se trouve que c’est le canal qu’utilisent la plupart des appelants, ce qui vaut la peine d’être écrit, mais ce n’est pas la même chose que de dire que l’information est indisponible.

Rien de tout cela n’est une découverte non plus. Le comportement a été signalé le 3 août 2026 sous ag-ui-protocol/ag-ui#2300, et la PR #2354 est ouverte depuis le 7 août 2026 avec le correctif. Les deux étaient encore ouvertes à l’écriture de cette page. L’argument en faveur de l’outil n’est pas la nouveauté, c’est la portée : le bug a été trouvé une fois, à la main, sur un seul flux, et la boucle fait apparaître 54 coupures de cette forme sur 34 flux sans que personne ait eu à deviner où regarder.

Six bibliothèques, la même question, quatre réponses nettes

Le dépôt embarque des sondes qui demandent à cinq autres bases de code ce qu’elles disent à un appelant quand le flux est coupé. Quatre d’entre elles répondent correctement :

Le MCP TypeScript SDK, lui, non. Quand la branche réponse d’un POST meurt, la requête attend l’expiration complète de son délai avant que l’appelant soit prévenu — à chaque point de coupure, y compris là où la moitié de la réponse était déjà arrivée.

Six interrogées en tout, AG-UI elle-même comprise — quatre nettes. Les deux qui répondent mal, AG-UI et le MCP SDK, avaient toutes deux été trouvées par quelqu’un d’autre avant. Voilà le compte honnête : c’est une manière de poser partout à la fois une vieille question, pas une source de constats inédits.

Les chiffres

Le citer

La bibliothèque a son propre DOI : 10.5281/zenodo.23002965. Un fichier CITATION.cff est livré dans le dépôt, si bien que le bouton Cite this repository de GitHub est renseigné à partir de lui.

Mainteneur

Maintenue par Redouane — ROSH™ Company Labs. Les bugs et les demandes de fonctionnalités vont dans les issues GitHub. Le rapport le plus utile que ce projet puisse recevoir, c’est un corpus où le résumé a qualifié de constat quelque chose qui n’en était pas un.

FAQ

Les questions que posent les développeurs

Que fait cut-at-k, concrètement ?

Il prend un flux et le fait passer deux fois dans votre client : une fois entier, et une fois pour chaque préfixe. À chaque coupure, il pose deux questions. La tête a-t-elle changé — car une troncature peut perdre la fin, mais elle ne doit jamais modifier ce qui précédait la coupure. Et le consommateur le dit-il — car une exécution interrompue ne doit pas rapporter ce que la même exécution rapporte lorsqu’elle va à son terme. C’est à la seconde question que les problèmes se logent d’habitude, et ils sont silencieux quand ils sont là.

\n
Pourquoi couper à chaque point plutôt que tester une seule déconnexion à la fin ?

Parce qu’un seul test de déconnexion ne couvre qu’un seul point de coupure. Une coupure après le dernier événement de contenu ne perd rien et ne pose aucun problème. Quelques événements plus tôt, la même coupure peut faire disparaître un message entier alors que l’appel se résout normalement — sur le corpus AG-UI, c’est conformant-run-is-quiet, coupée après son quatrième événement. Sur l’ensemble de ces fixtures, 227 coupures en ont donné 54 qui avaient perdu quelque chose sans rien en dire, réparties sur 34 flux. Le bug qui est derrière a été trouvé une fois, à la main, sur un seul flux. Couvrir cet étalement à la main suppose d’écrire 227 tests de déconnexion et de deviner le bon point de coupure dans chacun.

\n
Doit-il comprendre mon protocole — SSE, WebSocket, un format maison ?

Non. Vous passez un tableau d’événements et une fonction de rejeu, et il ne regarde jamais à l’intérieur d’un événement. Des server-sent events, des trames WebSocket, du JSON Lines ou un tableau assemblé à la main fonctionnent de la même façon. Trois points d’entrée portent le travail : severAtEveryPoint({events, label, replay}) lance les rejeux, summarise() les réduit, format() imprime le rapport. Zéro dépendance, dix fichiers, MIT.

\n
Couper un flux de 227 façons ne produit-il pas 227 fausses alertes ?

C’est exactement ce que faisait la première version : elle déclarait 154 coupures problématiques sur un corpus où il y en avait 54. Trois situations se ressemblent trait pour trait dans les chiffres bruts : une coupure qui n’a rien perdu, un flux invalide à dessein, et une coupure qui a perdu du contenu tout en étant rapportée comme complète. Seule la troisième est un constat. summarise() les sépare désormais, et ne signale aucun défaut tant que vous ne lui avez pas dit ce qu’est le contenu.

\n
Qu’est-ce que le prédicat lostContent, et pourquoi dois-je l’écrire moi-même ?

C’est la fonction qui décide si une coupure a réellement fait tomber quelque chose dont un consommateur se soucierait. Vous seul le savez pour votre protocole : un événement de clôture n’est pas du contenu, un corps de message si. Sans ce prédicat, summarise() ne signale aucun défaut du tout. C’est délibéré. Un outil qui devine ce qui compte comme contenu devinera mal, et un rapport auquel personne ne se fie est pire que pas de rapport.

\n
Que signifie « écarté comme invalide à dessein » dans la sortie ?

Les corpus de conformité contiennent volontairement des flux malformés, pour vérifier qu’un client les rejette. Couper avant l’erreur volontaire fait livrer au préfixe plus que ce que livre le flux entier, ce qui inverse la comparaison et ne veut plus rien dire. Ces flux sont mis de côté — 18 sur les 66 du passage AG-UI — et rapportés sous forme de décompte plutôt que supprimés en silence, pour que vous voyiez toujours quelle part du corpus n’a pas été réellement exercée.

\n
Qu’a-t-il trouvé dans AG-UI ?

66 des 68 fixtures de conformité ont été rejouées à travers un vrai HttpAgent, sur HTTP et SSE, avec @ag-ui/client 1.0.0. Cela a donné 48 flux et 227 coupures. 154 coupures ont rapporté la même chose que l’exécution entière ; parmi elles, 54 avaient bel et bien perdu quelque chose, réparties sur 34 flux ; 52 de ces 54 n’avaient pas non plus d’événement terminal. Le plus petit cas : coupez conformant-run-is-quiet après son quatrième événement, et les deux appels se résolvent, avec un message chacun et aucun champ disant lequel a été tronqué.

\n
Est-ce un bug d’AG-UI que personne ne connaissait ?

Non, et la page le dit. Il a été signalé le 3 août 2026 sous ag-ui-protocol/ag-ui#2300, et la PR #2354 est ouverte depuis le 7 août 2026 avec le correctif ; les deux étaient ouvertes à l’écriture de ces lignes. Ce qui est proposé ici n’est pas la nouveauté. Le bug a été trouvé une fois, à la main, sur un seul flux — la boucle fait apparaître 54 coupures de cette forme sur 34 flux sans que personne ait à deviner où regarder.

\n
Un abonné AG-UI ne peut-il pas déjà savoir qu’une exécution a été tronquée ?

Si, et cette limite a sa place dans tout résumé honnête. onRunFinishedEvent se déclenche sur l’exécution complète et pas sur l’exécution tronquée, tandis que onRunFinalized se déclenche sur les deux : un abonné peut donc voir la différence dès aujourd’hui. L’affirmation plus étroite est celle qui est faite ici : l’appel attendu ne la fait pas remonter. RunAgentResult n’a aucun champ pour cela, les deux appels se résolvent, et le nombre de messages coïncide. C’est pourtant le canal qu’utilisent la plupart des appelants.

\n
Quelles autres bibliothèques ont été vérifiées, et avec quel résultat ?

Cinq, dans le répertoire des sondes : le Vercel AI SDK, LangGraph JS, Mastra, le OpenAI Node SDK et le MCP TypeScript SDK. Quatre sont revenues nettes. Le Vercel AI SDK rapporte une raison de fin other sur une exécution coupée, ou lève une erreur quand aucun texte n’est passé ; le checkpoint de LangGraph s’arrête là où le consommateur interrompu s’est arrêté et indique où reprendre ; la mémoire de Mastra contient encore ce qui a été montré au consommateur ; la réponse finale du OpenAI Node SDK conserve l’appel d’outil diffusé et ne prétend jamais être allée à son terme.

\n
Qu’est-ce qui ne va pas dans le MCP TypeScript SDK ?

Quand la branche réponse d’un POST meurt, la requête attend l’expiration complète de son délai avant que l’appelant soit prévenu — à chaque point de coupure testé, y compris là où la moitié de la réponse était déjà arrivée. L’appelant ne reçoit pas tant une mauvaise réponse qu’aucune réponse pendant longtemps. Là encore, quelqu’un d’autre l’avait trouvé avant cet outil : six bibliothèques interrogées, quatre nettes, et les deux qui répondent mal avaient déjà fait l’objet de signalements.

\n
Comment le faire tourner sur mon propre client ?

Collectez un flux sous forme de tableau d’événements — une fixture enregistrée suffit. Écrivez une fonction de rejeu qui passe un préfixe à votre client comme le ferait la production, sur un vrai transport si possible, et qui renvoie ce que reçoit votre appelant. Passez les deux à severAtEveryPoint avec un label, ajoutez un prédicat lostContent pour votre protocole, puis summarise() et format(). Commencez par un flux auquel vous faites déjà confiance : le résultat intéressant est la coupure que vous n’auriez pas pensé à écrire.

\n
Quelle est sa maturité, honnêtement ?

Trois jours à l’écriture de ces lignes. Première publication le 25 septembre 2026, actuellement en 0.1.3, et 0 étoile sur GitHub. Elle a 21 tests, 21 passent, et dix fichiers sans aucune dépendance : la lire de bout en bout coûte une après-midi au plus. Lisez-la avant de faire confiance à son rapport. Ce conseil n’est pas de la modestie, c’est la seule base sur laquelle un outil de trois jours devrait être utilisé.

\n
Puis-je le citer ?

Oui. Il a son propre DOI, 10.5281/zenodo.23002965, et livre un CITATION.cff : le bouton Cite this repository de GitHub est renseigné à partir de lui. Le paquet est sous licence MIT et publié avec une attestation de provenance vérifiée, si bien que l’archive présente sur npm peut être retracée jusqu’au workflow qui l’a construite.