Merge pull request '🐛 Ne plus compter l'attente qui précède un message d'une autre session (1.0.1)' (#3) from fix/2-attente-message-inter-session into develop

Reviewed-on: #3
Reviewed-by: bbaudouin <b.baudouin@pm.me>
This commit is contained in:
2026-10-26 10:23:11 +01:00
5 changed files with 105 additions and 13 deletions
+11
View File
@@ -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`.
+3 -2
View File
@@ -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/*/<session>.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
+1
View File
@@ -0,0 +1 @@
1.0.1
+30 -7
View File
@@ -15,8 +15,10 @@ Lit le transcript de la session (~/.claude/projects/*/<session>.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:
+60 -4
View File
@@ -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: "
'<cross-session-message from="autre">…</cross-session-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", "<system-reminder>…</system-reminder>"),
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", "<system-reminder>…</system-reminder>"),
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: