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:
parent
840c68ff1a
commit
f53e98d5a3
7 changed files with 100 additions and 23 deletions
17
CHANGELOG.md
17
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
|
||||
`<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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
tests/fixtures/sessions/usage/mixed.jsonl
vendored
2
tests/fixtures/sessions/usage/mixed.jsonl
vendored
|
|
@ -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": ""}}]}}
|
||||
|
|
|
|||
|
|
@ -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."}]}}
|
||||
|
|
|
|||
|
|
@ -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."}]}}
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Reference in a new issue