Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sensor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ apply: top-level tool argument strings and tool results are truncated at 1,000
characters; non-text content blocks and separately spilled tool-output files are
not imported. Contract tests use synthetic transcripts and do not launch Claude.

Tools are classified by execution type: `Bash` shell commands emit
`tool_type: "terminal_command"`, while MCP tools namespaced as `mcp__<server>__<tool>`
record `tool_type: "mcp_tool"` with `server_name` populated. Failed and blocked
tool calls (`is_error: true`) set `status: "error"` and retain the error output.

### Claude Desktop Agent Mode

The `claude_desktop` source covers Claude Desktop's local agent mode (released as
Expand Down
59 changes: 51 additions & 8 deletions Sensor/adr_sensor/parsers/claude_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,25 @@ def parse_jsonl_file(self, file_path: Path) -> List[AgentEvent]:

return entries

@staticmethod
def _classify_tool(tool_name: str) -> Tuple[str, Optional[str]]:
"""Classify a Claude Code tool into tool_type and optional server_name.

Claude Code names tools as:
- 'Bash': Terminal command execution ('terminal_command')
- 'mcp__<server>__<tool>': MCP server tool invocation ('mcp_tool')
- Others (e.g. Read, Edit, Glob, Grep, View): Built-in tool usage ('tool_use')
"""
if not isinstance(tool_name, str):
return "tool_use", None
if tool_name in ("Bash", "bash"):
return "terminal_command", None
if tool_name.startswith("mcp__"):
parts = tool_name.split("__", 2)
if len(parts) >= 2 and parts[1]:
return "mcp_tool", parts[1]
return "tool_use", None

def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Extract only the needed data from a message object."""
msg_type = obj.get("type")
Expand Down Expand Up @@ -336,14 +355,30 @@ def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]]
self.record_diagnostic("record_shape_error")
continue
result_content = item.get("content", "")
if "toolUseResult" in obj and isinstance(obj["toolUseResult"], dict):
result_content = obj["toolUseResult"].get("result", result_content)
tur = obj.get("toolUseResult") if isinstance(obj.get("toolUseResult"), dict) else {}
if tur:
result_content = tur.get("result", result_content)
if not result_content and "error" in tur:
result_content = tur.get("error", "")

result_content = self._normalize_result_content(result_content)

if result_content and isinstance(result_content, str):
result_content = truncate_middle(result_content, max_length=1000, edge_chars=400)
tool_results.append({"tool_use_id": tool_use_id, "result": result_content})

is_error = bool(
item.get("is_error") is True
or item.get("is_error") == "true"
or tur.get("is_error") is True
or tur.get("is_error") == "true"
or tur.get("status") == "error"
or "error" in tur
)
tool_results.append({
"tool_use_id": tool_use_id,
"result": result_content,
"is_error": is_error,
})

extracted["tool_results"] = tool_results

Expand All @@ -354,7 +389,7 @@ def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]]
if not isinstance(item, dict) or item.get("type") != "tool_use":
continue
raw_input = item.get("input", {})
name = item.get("name", "unknown")
name = item.get("name") or "unknown"
if not isinstance(raw_input, dict) or not isinstance(name, str):
self.record_diagnostic("record_shape_error")
continue
Expand Down Expand Up @@ -391,14 +426,19 @@ def _create_entry_from_extracted_session(
for tool_result in tool_results:
tool_use_id = tool_result.get("tool_use_id")
result = tool_result.get("result")
is_error = tool_result.get("is_error", False)
if tool_use_id in pending_tools:
message_index, tool_index = pending_tools[tool_use_id]
old_message = chat_history[message_index]
old_tool = old_message.tools[tool_index]
new_tools = list(old_message.tools)
status = "error" if is_error else "success"
error = result if is_error and result else None
new_tools[tool_index] = replace(
new_tools[tool_index],
old_tool,
result=result,
status="success" if result else "unknown",
status=status,
error=error,
)
chat_history[message_index] = replace(old_message, tools=new_tools)

Expand All @@ -412,9 +452,12 @@ def _create_entry_from_extracted_session(
tools = []

for tool_data in msg_data.get("tools", []):
tool_name = tool_data.get("name") or "unknown"
tool_type, server_name = self._classify_tool(tool_name)
tool = ToolUsage(
tool_name=tool_data.get("name", "unknown"),
tool_type="tool_use",
tool_name=tool_name,
tool_type=tool_type,
server_name=server_name,
arguments=tool_data.get("input", {}),
result=None,
)
Expand Down
216 changes: 216 additions & 0 deletions Sensor/tests/test_claude_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,219 @@ def test_incomplete_records_are_never_joined_across_physical_lines(tmp_path):

assert _contents(entries) == ["Independent complete message"]
assert entries[0].session_context["event_count"] == 1


@pytest.mark.parametrize(
("tool_name", "expected_type", "expected_server"),
[
("Bash", "terminal_command", None),
("bash", "terminal_command", None),
("mcp__github__create_issue", "mcp_tool", "github"),
("mcp__postgres_db__query_rows", "mcp_tool", "postgres_db"),
("mcp__fetch__fetch", "mcp_tool", "fetch"),
("mcp__server", "mcp_tool", "server"),
("mcp__", "tool_use", None),
("mcp____tool", "tool_use", None),
("Read", "tool_use", None),
("Edit", "tool_use", None),
("Glob", "tool_use", None),
("Grep", "tool_use", None),
("unknown_tool", "tool_use", None),
(None, "tool_use", None),
],
)
def test_classify_tool_direct(tool_name, expected_type, expected_server):
tool_type, server_name = ClaudeParser._classify_tool(tool_name)
assert tool_type == expected_type
assert server_name == expected_server


def test_tool_classification_in_parsed_session(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Run multiple operations"),
_record(
"assistant",
[
{"type": "tool_use", "id": "call_1", "name": "Bash", "input": {"command": "ls -la"}},
{
"type": "tool_use",
"id": "call_2",
"name": "mcp__github__create_issue",
"input": {"title": "bug"},
},
{"type": "tool_use", "id": "call_3", "name": "Edit", "input": {"file_path": "foo.py"}},
],
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
assert len(assistant_msg.tools) == 3

assert assistant_msg.tools[0].tool_name == "Bash"
assert assistant_msg.tools[0].tool_type == "terminal_command"
assert assistant_msg.tools[0].server_name is None

assert assistant_msg.tools[1].tool_name == "mcp__github__create_issue"
assert assistant_msg.tools[1].tool_type == "mcp_tool"
assert assistant_msg.tools[1].server_name == "github"

assert assistant_msg.tools[2].tool_name == "Edit"
assert assistant_msg.tools[2].tool_type == "tool_use"
assert assistant_msg.tools[2].server_name is None


def test_server_name_and_tool_type_survive_tool_result_merge(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Fetch website content"),
_record(
"assistant",
[
{
"type": "tool_use",
"id": "fetch_1",
"name": "mcp__fetch__fetch_url",
"input": {"url": "https://uber.com"},
}
],
),
_record(
"user",
[{"type": "tool_result", "tool_use_id": "fetch_1", "content": "<html>Uber</html>"}],
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
assert len(assistant_msg.tools) == 1

tool = assistant_msg.tools[0]
assert tool.tool_name == "mcp__fetch__fetch_url"
assert tool.tool_type == "mcp_tool"
assert tool.server_name == "fetch"
assert tool.status == "success"
assert tool.result == "<html>Uber</html>"
assert tool.error is None


def test_is_error_flag_marks_status_error_and_populates_error_field(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Attempt unauthorized command"),
_record(
"assistant",
[{"type": "tool_use", "id": "bash_1", "name": "Bash", "input": {"command": "cat /etc/shadow"}}],
),
_record(
"user",
[
{
"type": "tool_result",
"tool_use_id": "bash_1",
"content": "Permission denied",
"is_error": True,
}
],
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
tool = assistant_msg.tools[0]

assert tool.tool_name == "Bash"
assert tool.tool_type == "terminal_command"
assert tool.status == "error"
assert tool.error == "Permission denied"
assert tool.result == "Permission denied"


def test_tool_use_result_envelope_error_and_fallback(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Query MCP server"),
_record(
"assistant",
[{"type": "tool_use", "id": "db_1", "name": "mcp__db__query", "input": {"sql": "SELECT 1"}}],
),
_record(
"user",
[{"type": "tool_result", "tool_use_id": "db_1", "content": ""}],
toolUseResult={"error": "Connection refused to database", "is_error": True},
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
tool = assistant_msg.tools[0]

assert tool.tool_name == "mcp__db__query"
assert tool.tool_type == "mcp_tool"
assert tool.server_name == "db"
assert tool.status == "error"
assert tool.error == "Connection refused to database"
assert tool.result == "Connection refused to database"


def test_empty_result_without_error_has_success_status(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Touch file"),
_record(
"assistant",
[{"type": "tool_use", "id": "touch_1", "name": "Bash", "input": {"command": "touch empty.txt"}}],
),
_record(
"user",
[{"type": "tool_result", "tool_use_id": "touch_1", "content": ""}],
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
tool = assistant_msg.tools[0]

assert tool.tool_type == "terminal_command"
assert tool.status == "success"
assert tool.result == ""
assert tool.error is None


def test_null_or_absent_tool_name_does_not_drop_session(tmp_path):
path = _write_records(
tmp_path / "session.jsonl",
[
_record("user", "Run tool with null name"),
_record(
"assistant",
[{"type": "tool_use", "id": "call_null", "name": None, "input": {}}],
),
],
)

entries = ClaudeParser().parse_jsonl_file(path)
assert len(entries) == 1
assistant_msg = entries[0].chat_history[1]
assert len(assistant_msg.tools) == 1
tool = assistant_msg.tools[0]
assert tool.tool_name == "unknown"
assert tool.tool_type == "tool_use"
assert tool.server_name is None