From 0b70aad64a27a46c7a99b8372f3486e24b1f3602 Mon Sep 17 00:00:00 2001 From: george-claude Date: Mon, 26 Oct 2026 10:15:32 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Ne=20plus=20compter=20l'attente?= =?UTF-8?q?=20qui=20pr=C3=A9c=C3=A8de=20un=20message=20d'une=20autre=20ses?= =?UTF-8?q?sion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une entrée user arrivée après la fin d'un tour de l'agent (dernière réponse du fil principal avec stop_reason autre que tool_use) ouvre une nouvelle plage, même écrite par Claude Code (isMeta) : message ou avis d'une autre session, commande slash, renommage. Une entrée isMeta écrite pendant un tour, ou après la fin d'un tour de sous-agent, ne coupe rien. Ajoute le fichier VERSION (1.0.1), vérifié égal à la constante de l'outil. Refs #2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KT2yBsd7nPivLnrEtcvuhX --- CHANGELOG.md | 11 +++++++ README.md | 5 +-- VERSION | 1 + outils/temps-session.py | 37 +++++++++++++++++---- tests/test-temps-session.py | 64 ++++++++++++++++++++++++++++++++++--- 5 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de799d..4080ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.1 + +- L'attente qui précède un message d'une autre session (`SendMessage`), ou + un avis d'inactivité d'une autre session, n'est plus comptée quand elle + dure moins de `--pause` : toute entrée `user` arrivée après la fin d'un + tour de l'agent ouvre une nouvelle plage, même écrite par Claude Code + (`isMeta`). Une entrée `isMeta` écrite pendant un tour (rappel système, + sortie de hook) ne coupe toujours pas la plage (#2). +- Fichier `VERSION` : la version de l'outil, la même que sa constante + `VERSION` (vérifié par les tests). + ## 1.0.0 Première version de `outils/temps-session.py`. diff --git a/README.md b/README.md index 4d0d914..6006b1a 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ Les évolutions de chaque version sont dans `CHANGELOG.md`. Calcule le temps actif d'une session Claude Code sur un ticket, prêt à saisir sur Gitea. Il lit le transcript de la session (`~/.claude/projects/*/.jsonl` et ses sous-agents) et ne compte que le temps où l'agent travaille : l'attente -d'une réponse de l'humain n'est jamais comptée. Il retire ce qui est déjà -saisi sur le ticket : le relancer ne double rien. +d'une réponse de l'humain, ou d'un message d'une autre session, n'est jamais +comptée. Il retire ce qui est déjà saisi sur le ticket : le relancer ne +double rien. Python 3.11 ou plus, sans dépendance. Il ne lit que le transcript et l'API Gitea (GET). **Il n'écrit ni n'envoie rien** : il affiche une ligne JSON par diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7dea76e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.1 diff --git a/outils/temps-session.py b/outils/temps-session.py index dfc38d4..ef0ff55 100644 --- a/outils/temps-session.py +++ b/outils/temps-session.py @@ -15,8 +15,10 @@ Lit le transcript de la session (~/.claude/projects/*/.jsonl et ses sous-agents) et ne compte que le temps ou l'agent travaille : du message de l'humain (ou de sa reponse a une question) jusqu'au dernier evenement avant qu'il reprenne la main. L'attente de l'humain n'est jamais comptee, qu'elle -dure une minute ou trois jours. Au sein d'un tour, un trou de plus de --pause -secondes (autorisation restee sans reponse) n'est pas compte non plus. +dure une minute ou trois jours ; celle d'un message d'une autre session non +plus (entree user, meme ecrite par Claude Code, arrivee apres la fin du +tour). Au sein d'un tour, un trou de plus de --pause secondes (autorisation +restee sans reponse) n'est pas compte non plus. Une ligne par seance de travail (tours separes de moins de --seance secondes), pour ne pas couvrir le ticket de saisies : sa duree est le temps actif cumule, @@ -41,7 +43,7 @@ Ne lit que le transcript et l'API Gitea (GET). N'ecrit rien, n'envoie rien. """ import argparse, datetime, glob, json, os, sys, tomllib, urllib.request -VERSION = "1.0.0" +VERSION = "1.0.1" MIN = 60 @@ -89,13 +91,34 @@ def evenements(session): questions.add(b.get("id")) ev.append((iso(d["timestamp"]), f in principal, d)) ev.sort(key=lambda e: e[0]) - return [(t, p and humain(d, questions)) for t, p, d in ev] + out, rendu = [], False + for t, p, d in ev: + fil = p and not d.get("isSidechain") + ouvre = p and (humain(d, questions) or (rendu and fil and d["type"] == "user")) + out.append((t, ouvre)) + if fil: + rendu = rendu_la_main(d) + return out + + +def rendu_la_main(d): + """Vrai si, apres l'entree d du transcript principal, l'agent attend : sa + reponse a fini son tour (stop_reason autre que tool_use). Toute entree + user qui suit relance alors le travail, meme ecrite par Claude Code + (message ou avis d'une autre session) : l'attente qui la precede n'est + pas comptee. Une entree user ecrite pendant un tour (rappel, sortie de + hook) ne change rien.""" + if d["type"] == "assistant": + fin = (d.get("message") or {}).get("stop_reason") + return fin is not None and fin != "tool_use" + return False def plages(ev, pause): - """Une plage s'arrete quand l'agent attend l'humain, quelle que soit la - duree de l'attente ; au sein d'un tour, un trou de plus de --pause (une - autorisation restee sans reponse) l'arrete aussi.""" + """Une plage s'arrete quand l'agent attend (l'humain ou une autre + session : voir rendu_la_main), quelle que soit la duree de l'attente ; au + sein d'un tour, un trou de plus de --pause (une autorisation restee sans + reponse) l'arrete aussi.""" out = [] for t, h in ev: if out and not h and (t - out[-1][1]).total_seconds() <= pause: diff --git a/tests/test-temps-session.py b/tests/test-temps-session.py index a2e7dbd..ec7efd9 100644 --- a/tests/test-temps-session.py +++ b/tests/test-temps-session.py @@ -31,9 +31,22 @@ def humain(t, texte): return {"type": "user", "timestamp": t, "message": {"role": "user", "content": texte}} -def agent(t): +def agent(t, fin=None): + """Réponse de l'agent ; fin = son stop_reason (end_turn : il rend la main).""" return {"type": "assistant", "timestamp": t, "message": {"role": "assistant", - "content": [{"type": "text", "text": "…"}]}} + "content": [{"type": "text", "text": "…"}], "stop_reason": fin}} + + +def meta(t, texte): + """Entrée user écrite par Claude Code, pas par l'humain : message d'une + autre session, avis, rappel système, sortie de hook.""" + return {"type": "user", "isMeta": True, "timestamp": t, + "message": {"role": "user", "content": texte}} + + +def resultat(t): + return {"type": "user", "timestamp": t, "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "x", "content": "…"}]}} # Deux tours : A de 10:00 à 10:05 (300 s), B de 10:08 à 10:10 (120 s). @@ -46,7 +59,7 @@ TRANSCRIPT = [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z") APPELS = [] -def lancer(ts, saisies, *options, jeton=True, refus=False): +def lancer(ts, saisies, *options, jeton=True, refus=False, session=SESSION): """Lance main() avec un Gitea qui renvoie `saisies` ; rend les lignes JSON. jeton=False n'ajoute pas --jeton ; refus=True attend que l'outil refuse, sans avoir appelé Gitea.""" @@ -58,7 +71,7 @@ def lancer(ts, saisies, *options, jeton=True, refus=False): ts.gitea = gitea APPELS.clear() sys.argv = ["temps-session.py", "--depot", "organisation/depot", "--ticket", "1", - "--session", SESSION, *(["--jeton", str(JETON)] if jeton else []), *options] + "--session", session, *(["--jeton", str(JETON)] if jeton else []), *options] sortie = io.StringIO() erreur = io.StringIO() try: @@ -194,6 +207,49 @@ jeton = "autre.jeton" verifier(sortie.getvalue().strip() == "temps-session.py " + ts.VERSION, "--version affiche %r" % sortie.getvalue()) + # 13. Le fichier VERSION et la constante de l'outil (qui se télécharge + # seul) disent la même version. + version = (DEPOT / "VERSION").read_text().strip() if (DEPOT / "VERSION").exists() else None + verifier(version == ts.VERSION, "VERSION dit %r, l'outil %r" % (version, ts.VERSION)) + + # 14 à 17 : entrées user écrites par Claude Code (isMeta). Chaque cas a + # sa session ; le tour A va de 10:00 à 10:05 (300 s). + def cas(numero, entrees): + session = "00000000-0000-0000-0000-0000000000%02d" % numero + (projet / (session + ".jsonl")).write_text("".join(json.dumps(d) + "\n" for d in entrees)) + return [l["time"] for l in lancer(ts, [], "--config", str(config), session=session)] + + tour_a = [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:05:00Z", "end_turn")] + reprise = [agent("2026-10-21T10:26:00Z", "tool_use"), resultat("2026-10-21T10:26:30Z"), + agent("2026-10-21T10:27:00Z", "end_turn")] + + # 14. L'agent a rendu la main ; une autre session lui écrit 20 min plus + # tard (sous --pause) et il reprend : l'attente n'est pas comptée, + # restent A et la reprise (10:25 à 10:27), 420 s. + temps = cas(14, tour_a + [meta("2026-10-21T10:25:00Z", "Another Claude session sent a message: " + '…')] + reprise) + verifier(temps == [420], "message d'une autre session après l'attente : %r au lieu de 420 s" % temps) + + # 15. Même chose pour un avis d'inactivité d'une autre session. + temps = cas(15, tour_a + [meta("2026-10-21T10:25:00Z", '[Cross-session idle notice] "autre" …')] + reprise) + verifier(temps == [420], "avis d'une autre session après l'attente : %r au lieu de 420 s" % temps) + + # 16. Une entrée isMeta écrite pendant un tour (rappel système, sortie de + # hook) ne coupe pas la plage : 300 s, pas 240. + temps = cas(16, [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z", "tool_use"), + meta("2026-10-21T10:03:00Z", "…"), + resultat("2026-10-21T10:04:00Z"), agent("2026-10-21T10:05:00Z", "end_turn")]) + verifier(temps == [300], "entrée isMeta pendant un tour : %r au lieu de 300 s" % temps) + + # 17. Un sous-agent qui finit son tour (isSidechain) ne rend pas la main + # de l'agent principal : la plage n'est pas coupée non plus. + fin_sous_agent = agent("2026-10-21T10:02:30Z", "end_turn") + fin_sous_agent["isSidechain"] = True + temps = cas(17, [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z", "tool_use"), + fin_sous_agent, meta("2026-10-21T10:03:00Z", "…"), + resultat("2026-10-21T10:04:00Z"), agent("2026-10-21T10:05:00Z", "end_turn")]) + verifier(temps == [300], "fin de tour d'un sous-agent : %r au lieu de 300 s" % temps) + if echecs: print("%d échec(s) :" % len(echecs)) for e in echecs: -- 2.54.0