diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8118a..1a0556d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +## [0.54.12] — 2026-05-14 + +### Fixed +- **Usage processor now extracts user-typed slash invocations.** Claude Code + records `/foo` and `/plugin:name` slash commands as + `/foo` XML tags embedded in user message + content; the previous `^\s*/` regex in `iter_events` only matched + raw `/foo` prefixes, which never appear in real session jsonls. Result on + production: `usage_events.command_name` and + `usage_session_summary.slash_commands` stayed NULL/0 for every actually-typed + slash invocation (`/clear`, `/exit`, `/plugin`, `/model`, plugin commands of + the form `/plugin:name`). Replaced with a `` tag scan; + `USAGE_PROCESSOR_VERSION` bumps 2 → 3. Operators wanting to rewrite + historical rows under the new logic call `POST /api/admin/usage/reprocess` + (CLI: `agnes admin telemetry reprocess`). Implicit Skill tool_use + extraction (LLM-decided invocations) is unchanged. + ## [0.54.11] — 2026-05-14 ### Changed diff --git a/pyproject.toml b/pyproject.toml index f10b50a..7c4e367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agnes-the-ai-analyst" -version = "0.54.11" +version = "0.54.12" description = "Agnes — AI Data Analyst platform for AI analytical systems" requires-python = ">=3.11,<3.14" license = "MIT" diff --git a/services/session_processors/usage_lib.py b/services/session_processors/usage_lib.py index 7a3cbe0..5265217 100644 --- a/services/session_processors/usage_lib.py +++ b/services/session_processors/usage_lib.py @@ -40,7 +40,7 @@ from dataclasses import dataclass from datetime import datetime, timezone, timedelta from typing import Iterator -USAGE_PROCESSOR_VERSION = 2 +USAGE_PROCESSOR_VERSION = 3 BUILTIN_TOOLS = frozenset({ "Bash", "Read", "Edit", "Write", "Grep", "Glob", "TodoWrite", @@ -48,8 +48,13 @@ BUILTIN_TOOLS = frozenset({ "LS", # also built-in }) -# Slash commands: "/something" or "/namespace:something" at start of user text -SLASH_RE = re.compile(r"^\s*/([A-Za-z][\w:-]*)") +# Claude Code wraps user-typed slash invocations as +# / inside the user message content +# (raw "/foo" plain text never reaches the jsonl). Tag may sit anywhere +# in the text — typically after a sibling — so we +# search rather than anchor at start. Name pattern matches both flat +# commands (`clear`, `exit`) and plugin-prefixed ones (`plugin:name`). +COMMAND_NAME_RE = re.compile(r"/([A-Za-z][\w:-]*)") # Event types to skip entirely _SKIP_TYPES = frozenset({ @@ -125,7 +130,9 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]: - Skill tool → also extracts skill_name - Task/Agent tools → event_type='subagent' - mcp__* tools → event_type='mcp_call' - - User messages starting with '/' → event_type='slash_command' + - User messages containing a /foo tag + (Claude Code's wire format for user-typed slash invocations) + → event_type='slash_command', command_name='foo' Skips: system, summary, file-history-snapshot, queue-operation, progress. """ @@ -191,7 +198,11 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]: ) elif turn_type == "user": - # Slash-command detection from text content + # Slash-invocation detection: scan user message content for + # /foo tags. content is normally a + # plain string on slash-invocation turns; tolerate the + # list-of-blocks shape too in case Claude Code's wire format + # shifts to structured content later. if isinstance(content, str): text_parts = [content] elif isinstance(content, list): @@ -206,8 +217,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]: for text in text_parts: if not text: continue - m = SLASH_RE.match(text) - if m: + for name in COMMAND_NAME_RE.findall(text): yield ParsedEvent( event_uuid=event_uuid, parent_uuid=parent_uuid, @@ -216,7 +226,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]: tool_name=None, skill_name=None, subagent_type=None, - command_name=m.group(1), + command_name=name, is_error=False, model=None, cwd=cwd, diff --git a/tests/fixtures/sessions/usage/mixed.jsonl b/tests/fixtures/sessions/usage/mixed.jsonl index 43a3b14..5c997c8 100644 --- a/tests/fixtures/sessions/usage/mixed.jsonl +++ b/tests/fixtures/sessions/usage/mixed.jsonl @@ -1,4 +1,4 @@ -{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/compound:debug start debugging"}} +{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "compound:debug\n/compound:debug\nstart debugging"}} {"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_bash_m", "name": "Bash", "input": {"command": "ls", "description": "List"}}]}} {"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_bash_m", "is_error": false, "content": [{"type": "text", "text": "file.txt"}]}]}} {"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_skill_m", "name": "Skill", "input": {"skill": "my-skill", "args": ""}}]}} diff --git a/tests/fixtures/sessions/usage/skill_curated.jsonl b/tests/fixtures/sessions/usage/skill_curated.jsonl index 56165ee..a84d097 100644 --- a/tests/fixtures/sessions/usage/skill_curated.jsonl +++ b/tests/fixtures/sessions/usage/skill_curated.jsonl @@ -1,4 +1,4 @@ -{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/my-skill do something"}} +{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "my-skill\n/my-skill\ndo something"}} {"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_skill_1", "name": "Skill", "input": {"skill": "my-skill", "args": "do something"}}]}} {"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_skill_1", "is_error": false, "content": [{"type": "text", "text": "Skill executed successfully"}]}]}} {"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Done."}]}} diff --git a/tests/fixtures/sessions/usage/slash_command.jsonl b/tests/fixtures/sessions/usage/slash_command.jsonl index cdd7934..6ff5588 100644 --- a/tests/fixtures/sessions/usage/slash_command.jsonl +++ b/tests/fixtures/sessions/usage/slash_command.jsonl @@ -1,4 +1,4 @@ -{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/compound:debug some args here"}} +{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "compound:debug\n/compound:debug\nsome args here"}} {"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Debugging..."}]}} {"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:05.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "Thanks"}} {"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:06.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "You are welcome."}]}} diff --git a/tests/test_session_processor_usage.py b/tests/test_session_processor_usage.py index 3368b09..4fe8080 100644 --- a/tests/test_session_processor_usage.py +++ b/tests/test_session_processor_usage.py @@ -164,17 +164,6 @@ class TestCuratedSkill: s = _summary(conn, "skill_curated.jsonl") assert s["skill_invocations"] == 1 - def test_slash_command_also_extracted(self, tmp_path, monkeypatch): - """skill_curated fixture has a /my-skill slash command in user message.""" - conn = _fresh_db(tmp_path, monkeypatch) - _seed_attribution(conn) - _process("skill_curated.jsonl", conn) - evts = _events(conn) - types = [e["event_type"] for e in evts] - # Both tool_use (Skill) and slash_command should be present - assert "tool_use" in types - assert "slash_command" in types - class TestFleaSkill: def test_flea_attribution(self, tmp_path, monkeypatch): @@ -384,3 +373,64 @@ class TestMultiToolTurnDedup: "SELECT COUNT(*) FROM usage_events WHERE session_id='sess-multi'" ).fetchone()[0] assert n == 2, f"expected 2 events (one per tu_xxx), got {n}" + + +class TestCommandNameTagExtraction: + """Slash invocations arrive as /foo embedded in + user message content (Claude Code's wire format). Unit-test iter_events + against synthetic turns so a future shape shift doesn't silently regress.""" + + @staticmethod + def _user_turn(content): + return { + "type": "user", + "uuid": "u1", + "parentUuid": None, + "sessionId": "sess-cn", + "timestamp": "2026-05-14T10:00:00.000Z", + "cwd": "/workspace", + "message": {"role": "user", "content": content}, + } + + def test_extracts_command_name_from_string_content(self): + from services.session_processors.usage_lib import iter_events + turn = self._user_turn( + "/clear\n" + ) + events = list(iter_events([turn])) + assert len(events) == 1 + assert events[0].event_type == "slash_command" + assert events[0].command_name == "clear" + + def test_extracts_command_name_from_text_block(self): + """Defensive: same regex behavior when content arrives as a list-of-blocks + instead of a plain string, in case Claude Code's wire format shifts.""" + from services.session_processors.usage_lib import iter_events + turn = self._user_turn( + [{"type": "text", "text": "/plugin:name"}] + ) + events = list(iter_events([turn])) + assert len(events) == 1 + assert events[0].command_name == "plugin:name" + + def test_command_name_not_at_start_still_matches(self): + """Real Claude Code prepends a sibling before the + tag — regex must search, not anchor at start.""" + from services.session_processors.usage_lib import iter_events + turn = self._user_turn( + "foo\n" + "/foo\n" + "some arg" + ) + events = list(iter_events([turn])) + assert len(events) == 1 + assert events[0].command_name == "foo" + + def test_plain_text_without_tag_does_not_match(self): + """A user message that happens to contain '/foo' as prose, but no + tag, must NOT yield a slash_command event — that's the + whole point of switching from the old `^\\s*/` regex.""" + from services.session_processors.usage_lib import iter_events + turn = self._user_turn("Hello world, see /not-a-command-just-prose for context.") + events = list(iter_events([turn])) + assert events == []