diff --git a/tests/test_trace.py b/tests/test_trace.py index cdb7c3e..485b7bb 100644 --- a/tests/test_trace.py +++ b/tests/test_trace.py @@ -205,6 +205,25 @@ def user(content, **flags): assert trace.claude_prompt_count(p) == 3 +def test_a_claude_background_task_notification_is_no_prompt(tmp_path): + """Claude Code tells the model that a background task finished in a `user` entry of its + own, `origin.kind` "task-notification" (2.1.317). The person typed one prompt here. The + notification still starts a turn of its own: the model answers it.""" + note = {"type": "user", "promptSource": "system", "turnOrigin": "task_notification", + "origin": {"kind": "task-notification", "producer": "session-task"}, + "message": {"role": "user", "content": ( + "\nb1\ncompleted\n" + "Background command \"pytest\" completed (exit code 0)\n" + "")}} + p = _jsonl(tmp_path / "c.jsonl", [ + {"type": "user", "message": {"role": "user", "content": "run the tests in the background"}}, + {"type": "assistant", "message": {"role": "assistant", "content": "started"}}, + note, + {"type": "assistant", "message": {"role": "assistant", "content": "they pass"}}]) + assert trace.claude_prompt_count(p) == 1 + assert len(list(trace.claude_prompt_turns(p))) == 2 + + def test_a_codex_session_counts_one_prompt_per_turn(tmp_path): turn = [{"type": "turn_context", "payload": {"turn_id": "t"}}, {"type": "event_msg", "payload": {"type": "task_started"}}, diff --git a/tokeneconomizer/trace/claude.py b/tokeneconomizer/trace/claude.py index 04f7bb0..ea79b13 100644 --- a/tokeneconomizer/trace/claude.py +++ b/tokeneconomizer/trace/claude.py @@ -88,15 +88,23 @@ def _is_prompt(ev): "[Request interrupted")) +def _is_notification(ev): + """Is this entry Claude Code telling the model that a background task finished? It starts a + turn, as a prompt does, but nobody typed it (2.1.317: `origin.kind` "task-notification").""" + origin = ev.get("origin") + return isinstance(origin, dict) and origin.get("kind") == "task-notification" + + def claude_prompt_count(transcript_path): - """How many prompts the person gave a Claude Code session (`_is_prompt`).""" + """How many prompts the person gave a Claude Code session (`_is_prompt`, less the + background-task notifications).""" n = 0 for line in _lines(transcript_path): try: ev = json.loads(line) except ValueError: continue - n += isinstance(ev, dict) and _is_prompt(ev) + n += isinstance(ev, dict) and _is_prompt(ev) and not _is_notification(ev) return n