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]
|
## [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
|
## [0.54.11] — 2026-05-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "agnes-the-ai-analyst"
|
name = "agnes-the-ai-analyst"
|
||||||
version = "0.54.11"
|
version = "0.54.12"
|
||||||
description = "Agnes — AI Data Analyst platform for AI analytical systems"
|
description = "Agnes — AI Data Analyst platform for AI analytical systems"
|
||||||
requires-python = ">=3.11,<3.14"
|
requires-python = ">=3.11,<3.14"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
USAGE_PROCESSOR_VERSION = 2
|
USAGE_PROCESSOR_VERSION = 3
|
||||||
|
|
||||||
BUILTIN_TOOLS = frozenset({
|
BUILTIN_TOOLS = frozenset({
|
||||||
"Bash", "Read", "Edit", "Write", "Grep", "Glob", "TodoWrite",
|
"Bash", "Read", "Edit", "Write", "Grep", "Glob", "TodoWrite",
|
||||||
|
|
@ -48,8 +48,13 @@ BUILTIN_TOOLS = frozenset({
|
||||||
"LS", # also built-in
|
"LS", # also built-in
|
||||||
})
|
})
|
||||||
|
|
||||||
# Slash commands: "/something" or "/namespace:something" at start of user text
|
# Claude Code wraps user-typed slash invocations as
|
||||||
SLASH_RE = re.compile(r"^\s*/([A-Za-z][\w:-]*)")
|
# <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
|
# Event types to skip entirely
|
||||||
_SKIP_TYPES = frozenset({
|
_SKIP_TYPES = frozenset({
|
||||||
|
|
@ -125,7 +130,9 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
|
||||||
- Skill tool → also extracts skill_name
|
- Skill tool → also extracts skill_name
|
||||||
- Task/Agent tools → event_type='subagent'
|
- Task/Agent tools → event_type='subagent'
|
||||||
- mcp__* tools → event_type='mcp_call'
|
- 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.
|
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":
|
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):
|
if isinstance(content, str):
|
||||||
text_parts = [content]
|
text_parts = [content]
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
|
|
@ -206,8 +217,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
|
||||||
for text in text_parts:
|
for text in text_parts:
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
m = SLASH_RE.match(text)
|
for name in COMMAND_NAME_RE.findall(text):
|
||||||
if m:
|
|
||||||
yield ParsedEvent(
|
yield ParsedEvent(
|
||||||
event_uuid=event_uuid,
|
event_uuid=event_uuid,
|
||||||
parent_uuid=parent_uuid,
|
parent_uuid=parent_uuid,
|
||||||
|
|
@ -216,7 +226,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
|
||||||
tool_name=None,
|
tool_name=None,
|
||||||
skill_name=None,
|
skill_name=None,
|
||||||
subagent_type=None,
|
subagent_type=None,
|
||||||
command_name=m.group(1),
|
command_name=name,
|
||||||
is_error=False,
|
is_error=False,
|
||||||
model=None,
|
model=None,
|
||||||
cwd=cwd,
|
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": "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": "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": ""}}]}}
|
{"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": "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": "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."}]}}
|
{"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": "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": "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."}]}}
|
{"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")
|
s = _summary(conn, "skill_curated.jsonl")
|
||||||
assert s["skill_invocations"] == 1
|
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:
|
class TestFleaSkill:
|
||||||
def test_flea_attribution(self, tmp_path, monkeypatch):
|
def test_flea_attribution(self, tmp_path, monkeypatch):
|
||||||
|
|
@ -384,3 +373,64 @@ class TestMultiToolTurnDedup:
|
||||||
"SELECT COUNT(*) FROM usage_events WHERE session_id='sess-multi'"
|
"SELECT COUNT(*) FROM usage_events WHERE session_id='sess-multi'"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert n == 2, f"expected 2 events (one per tu_xxx), got {n}"
|
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