fix(usage): extract <command-name> slash invocations (release 0.54.12) (#303)

UsageProcessor missed every user-typed slash invocation because the
SLASH_RE regex (^\s*/<name>) expected a raw "/foo" prefix that Claude
Code never writes. Real session jsonls wrap slash commands in a
<command-name>/foo</command-name> XML tag inside user message content.
Result on production: usage_events.command_name and
usage_session_summary.slash_commands stayed NULL/0 for /clear, /exit,
plugin commands like /plugin:name — verified on 17 dev-VM jsonls
holding 25 <command-name> tags / 0 extracted rows.

Replaces SLASH_RE with COMMAND_NAME_RE that searches for the tag
anywhere in the user text (the tag sits after a <command-message>
sibling). USAGE_PROCESSOR_VERSION bumps 2 → 3; operators wanting to
rewrite historical rows under the new logic call
POST /api/admin/usage/reprocess (agnes admin telemetry reprocess).

Fixtures slash_command.jsonl, mixed.jsonl, skill_curated.jsonl
rewritten from the unrealistic "/foo args" string format to the real
<command-name> tag wrapper — existing assertions stay green against
the new format, which is the regression baseline going forward. Adds
TestCommandNameTagExtraction (4 unit tests on iter_events) covering
string content, list-of-text-blocks content, mid-text tag position,
and plain-prose "/x" non-match.

Implicit Skill tool_use extraction (LLM-decided invocations)
unchanged.

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
This commit is contained in:
minasarustamyan 2026-05-14 15:33:57 +02:00 committed by GitHub
parent 840c68ff1a
commit f53e98d5a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 100 additions and 23 deletions

View file

@ -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
`<command-name>/foo</command-name>` XML tags embedded in user message
content; the previous `^\s*/<name>` 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 `<command-name>` 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

View file

@ -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"

View file

@ -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
# <command-name>/<name></command-name> inside the user message content
# (raw "/foo" plain text never reaches the jsonl). Tag may sit anywhere
# in the text — typically after a <command-message> 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"<command-name>/([A-Za-z][\w:-]*)</command-name>")
# 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 <command-name>/foo</command-name> 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
# <command-name>/foo</command-name> 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,

View file

@ -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": "<command-message>compound:debug</command-message>\n<command-name>/compound:debug</command-name>\n<command-args>start debugging</command-args>"}}
{"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": ""}}]}}

View file

@ -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": "<command-message>my-skill</command-message>\n<command-name>/my-skill</command-name>\n<command-args>do something</command-args>"}}
{"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."}]}}

View file

@ -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": "<command-message>compound:debug</command-message>\n<command-name>/compound:debug</command-name>\n<command-args>some args here</command-args>"}}
{"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."}]}}

View file

@ -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 <command-name>/foo</command-name> 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(
"<command-name>/clear</command-name>\n<command-args></command-args>"
)
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": "<command-name>/plugin:name</command-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 <command-message> sibling before the
<command-name> tag regex must search, not anchor at start."""
from services.session_processors.usage_lib import iter_events
turn = self._user_turn(
"<command-message>foo</command-message>\n"
"<command-name>/foo</command-name>\n"
"<command-args>some arg</command-args>"
)
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
<command-name> tag, must NOT yield a slash_command event that's the
whole point of switching from the old `^\\s*/<name>` 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 == []