906 lines
30 KiB
Python
906 lines
30 KiB
Python
import importlib
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from claude_agent_sdk.types import (
|
|
AssistantMessage,
|
|
ResultMessage,
|
|
TextBlock,
|
|
ToolResultBlock,
|
|
ToolUseBlock,
|
|
UserMessage,
|
|
)
|
|
|
|
import mlflow
|
|
import mlflow.claude_code.tracing as tracing_module
|
|
from mlflow.claude_code.tracing import (
|
|
CLAUDE_TRACING_LEVEL,
|
|
METADATA_KEY_CLAUDE_CODE_VERSION,
|
|
find_last_user_message_index,
|
|
get_hook_response,
|
|
parse_timestamp_to_ns,
|
|
process_sdk_messages,
|
|
process_transcript,
|
|
setup_logging,
|
|
)
|
|
from mlflow.entities.span import SpanType
|
|
from mlflow.tracing.constant import SpanAttributeKey, TraceMetadataKey
|
|
|
|
# ============================================================================
|
|
# TIMESTAMP PARSING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_parse_timestamp_to_ns_iso_string():
|
|
iso_timestamp = "2024-01-15T10:30:45.123456Z"
|
|
result = parse_timestamp_to_ns(iso_timestamp)
|
|
|
|
# Verify it returns an integer (nanoseconds)
|
|
assert isinstance(result, int)
|
|
assert result > 0
|
|
|
|
|
|
def test_parse_timestamp_to_ns_unix_seconds():
|
|
unix_timestamp = 1705312245.123456
|
|
result = parse_timestamp_to_ns(unix_timestamp)
|
|
|
|
# Should convert seconds to nanoseconds
|
|
expected = int(unix_timestamp * 1_000_000_000)
|
|
assert result == expected
|
|
|
|
|
|
def test_parse_timestamp_to_ns_large_number():
|
|
large_timestamp = 1705312245123
|
|
result = parse_timestamp_to_ns(large_timestamp)
|
|
|
|
# Function treats large numbers as seconds and converts to nanoseconds
|
|
# Just verify we get a reasonable nanosecond value
|
|
assert isinstance(result, int)
|
|
assert result > 0
|
|
|
|
|
|
# ============================================================================
|
|
# LOGGING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_setup_logging_creates_logger(monkeypatch, tmp_path):
|
|
monkeypatch.chdir(tmp_path)
|
|
logger = setup_logging()
|
|
|
|
# Verify logger was created
|
|
assert logger is not None
|
|
assert logger.name == "mlflow.claude_code.tracing"
|
|
|
|
# Verify log directory was created
|
|
log_dir = tmp_path / ".claude" / "mlflow"
|
|
assert log_dir.exists()
|
|
assert log_dir.is_dir()
|
|
|
|
|
|
def test_custom_logging_level():
|
|
setup_logging()
|
|
|
|
assert CLAUDE_TRACING_LEVEL > logging.INFO
|
|
assert CLAUDE_TRACING_LEVEL < logging.WARNING
|
|
assert logging.getLevelName(CLAUDE_TRACING_LEVEL) == "CLAUDE_TRACING"
|
|
|
|
|
|
def test_get_logger_lazy_initialization(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
# Force reload to reset the module state
|
|
importlib.reload(tracing_module)
|
|
|
|
log_dir = tmp_path / ".claude" / "mlflow"
|
|
|
|
# Before calling get_logger(), the log directory should NOT exist
|
|
assert not log_dir.exists()
|
|
|
|
# Call get_logger() for the first time - this should trigger initialization
|
|
logger1 = tracing_module.get_logger()
|
|
|
|
# After calling get_logger(), the log directory SHOULD exist
|
|
assert log_dir.exists()
|
|
assert log_dir.is_dir()
|
|
|
|
# Verify logger was created properly
|
|
assert logger1 is not None
|
|
assert logger1.name == "mlflow.claude_code.tracing"
|
|
|
|
# Call get_logger() again - should return the same logger instance
|
|
logger2 = tracing_module.get_logger()
|
|
assert logger2 is logger1
|
|
|
|
|
|
# ============================================================================
|
|
# HOOK RESPONSE TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_get_hook_response_success():
|
|
response = get_hook_response()
|
|
assert response == {"continue": True}
|
|
|
|
|
|
def test_get_hook_response_with_error():
|
|
response = get_hook_response(error="Test error")
|
|
assert response == {"continue": False, "stopReason": "Test error"}
|
|
|
|
|
|
def test_get_hook_response_with_additional_fields():
|
|
response = get_hook_response(custom_field="value")
|
|
assert response == {"continue": True, "custom_field": "value"}
|
|
|
|
|
|
# ============================================================================
|
|
# ASYNC TRACE LOGGING UTILITY TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_flush_trace_async_logging_calls_flush(monkeypatch):
|
|
mock_exporter = type("MockExporter", (), {"_async_queue": True})()
|
|
monkeypatch.setattr(tracing_module, "_get_trace_exporter", lambda: mock_exporter)
|
|
flushed = []
|
|
monkeypatch.setattr(mlflow, "flush_trace_async_logging", lambda: flushed.append(True))
|
|
tracing_module._flush_trace_async_logging()
|
|
assert len(flushed) == 1
|
|
|
|
|
|
def test_flush_trace_async_logging_skips_without_async_queue(monkeypatch):
|
|
mock_exporter = object() # no _async_queue attribute
|
|
monkeypatch.setattr(tracing_module, "_get_trace_exporter", lambda: mock_exporter)
|
|
flushed = []
|
|
monkeypatch.setattr(mlflow, "flush_trace_async_logging", lambda: flushed.append(True))
|
|
tracing_module._flush_trace_async_logging()
|
|
assert len(flushed) == 0
|
|
|
|
|
|
# ============================================================================
|
|
# INTEGRATION TESTS
|
|
# ============================================================================
|
|
|
|
# Sample Claude Code transcript for testing
|
|
DUMMY_TRANSCRIPT = [
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "What is 2 + 2?"},
|
|
"timestamp": "2025-01-15T10:00:00.000Z",
|
|
"sessionId": "test-session-123",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Let me calculate that for you."}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:01.000Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tool_123",
|
|
"name": "Bash",
|
|
"input": {"command": "echo $((2 + 2))"},
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:02.000Z",
|
|
},
|
|
{
|
|
"type": "user",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "tool_123", "content": "4"}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:03.000Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "The answer is 4."}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:04.000Z",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_transcript_file(tmp_path):
|
|
transcript_path = tmp_path / "transcript.jsonl"
|
|
with open(transcript_path, "w") as f:
|
|
for entry in DUMMY_TRANSCRIPT:
|
|
f.write(json.dumps(entry) + "\n")
|
|
return str(transcript_path)
|
|
|
|
|
|
def test_process_transript_creates_trace(mock_transcript_file):
|
|
trace = process_transcript(mock_transcript_file, "test-session-123")
|
|
|
|
# Verify trace was created
|
|
assert trace is not None
|
|
|
|
# Verify trace has spans
|
|
spans = list(trace.search_spans())
|
|
assert len(spans) > 0
|
|
|
|
# Verify root span and metadata
|
|
root_span = trace.data.spans[0]
|
|
assert root_span.name == "claude_code_conversation"
|
|
assert root_span.span_type == SpanType.AGENT
|
|
assert trace.info.trace_metadata.get("mlflow.trace.session") == "test-session-123"
|
|
|
|
|
|
def test_process_transcript_creates_spans(mock_transcript_file):
|
|
trace = process_transcript(mock_transcript_file, "test-session-123")
|
|
|
|
assert trace is not None
|
|
|
|
# Verify trace has spans
|
|
spans = list(trace.search_spans())
|
|
assert len(spans) > 0
|
|
|
|
# Find LLM and tool spans
|
|
llm_spans = [s for s in spans if s.span_type == SpanType.LLM]
|
|
tool_spans = [s for s in spans if s.span_type == SpanType.TOOL]
|
|
|
|
assert len(llm_spans) == 2
|
|
assert len(tool_spans) == 1
|
|
|
|
# Verify tool span has proper attributes
|
|
tool_span = tool_spans[0]
|
|
assert tool_span.name == "tool_Bash"
|
|
|
|
# Verify LLM spans have MESSAGE_FORMAT set to "anthropic" for Chat UI rendering
|
|
for llm_span in llm_spans:
|
|
assert llm_span.get_attribute(SpanAttributeKey.MESSAGE_FORMAT) == "anthropic"
|
|
|
|
# Verify LLM span outputs are in Anthropic response format
|
|
first_llm = llm_spans[0]
|
|
outputs = first_llm.outputs
|
|
assert outputs["type"] == "message"
|
|
assert outputs["role"] == "assistant"
|
|
assert isinstance(outputs["content"], list)
|
|
|
|
# Verify LLM span inputs contain messages in Anthropic format
|
|
inputs = first_llm.inputs
|
|
assert "messages" in inputs
|
|
messages = inputs["messages"]
|
|
assert any(m["role"] == "user" for m in messages)
|
|
|
|
|
|
def test_process_transcript_returns_none_for_nonexistent_file():
|
|
result = process_transcript("/nonexistent/path/transcript.jsonl", "test-session-123")
|
|
assert result is None
|
|
|
|
|
|
def test_process_transcript_links_trace_to_run(mock_transcript_file):
|
|
with mlflow.start_run() as run:
|
|
trace = process_transcript(mock_transcript_file, "test-session-123")
|
|
|
|
assert trace is not None
|
|
assert trace.info.trace_metadata.get(TraceMetadataKey.SOURCE_RUN) == run.info.run_id
|
|
|
|
|
|
# Sample Claude Code transcript with token usage for testing
|
|
DUMMY_TRANSCRIPT_WITH_USAGE = [
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Hello Claude!"},
|
|
"timestamp": "2025-01-15T10:00:00.000Z",
|
|
"sessionId": "test-session-usage",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Hello! How can I help you today?"}],
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": {"input_tokens": 150, "output_tokens": 25},
|
|
},
|
|
"timestamp": "2025-01-15T10:00:01.000Z",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_transcript_file_with_usage(tmp_path):
|
|
transcript_path = tmp_path / "transcript_with_usage.jsonl"
|
|
with open(transcript_path, "w") as f:
|
|
for entry in DUMMY_TRANSCRIPT_WITH_USAGE:
|
|
f.write(json.dumps(entry) + "\n")
|
|
return str(transcript_path)
|
|
|
|
|
|
def test_process_transcript_tracks_token_usage(mock_transcript_file_with_usage):
|
|
trace = process_transcript(mock_transcript_file_with_usage, "test-session-usage")
|
|
|
|
assert trace is not None
|
|
|
|
# Find the LLM span
|
|
spans = list(trace.search_spans())
|
|
llm_spans = [s for s in spans if s.span_type == SpanType.LLM]
|
|
|
|
assert len(llm_spans) == 1
|
|
llm_span = llm_spans[0]
|
|
|
|
# Verify token usage is tracked using the standardized CHAT_USAGE attribute
|
|
token_usage = llm_span.get_attribute(SpanAttributeKey.CHAT_USAGE)
|
|
assert token_usage is not None
|
|
assert token_usage["input_tokens"] == 150
|
|
assert token_usage["output_tokens"] == 25
|
|
assert token_usage["total_tokens"] == 175
|
|
|
|
# Verify trace-level token usage aggregation works
|
|
assert trace.info.token_usage is not None
|
|
assert trace.info.token_usage["input_tokens"] == 150
|
|
assert trace.info.token_usage["output_tokens"] == 25
|
|
assert trace.info.token_usage["total_tokens"] == 175
|
|
|
|
|
|
def test_process_transcript_preserves_cache_tokens(tmp_path):
|
|
"""Verify cache_read/cache_creation fields from Anthropic usage survive on the
|
|
CHAT_USAGE span attribute so prompt-cache hit rate is observable.
|
|
"""
|
|
transcript_entries = [
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Cached prompt"},
|
|
"timestamp": "2025-01-15T10:00:00.000Z",
|
|
"sessionId": "cache-transcript-session",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Answer using cache."}],
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": {
|
|
"input_tokens": 36,
|
|
"cache_creation_input_tokens": 23554,
|
|
"cache_read_input_tokens": 139035,
|
|
"output_tokens": 3344,
|
|
},
|
|
},
|
|
"timestamp": "2025-01-15T10:00:01.000Z",
|
|
},
|
|
]
|
|
|
|
transcript_path = tmp_path / "transcript_cache.jsonl"
|
|
with open(transcript_path, "w") as f:
|
|
for entry in transcript_entries:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
trace = process_transcript(str(transcript_path), "cache-transcript-session")
|
|
|
|
assert trace is not None
|
|
llm_spans = [s for s in trace.search_spans() if s.span_type == SpanType.LLM]
|
|
assert len(llm_spans) == 1
|
|
|
|
# input_tokens is the non-cached input the Anthropic API reports, matching
|
|
# mlflow.anthropic.autolog. Cache fields are exposed as separate keys so
|
|
# consumers can compute cache hit rate.
|
|
token_usage = llm_spans[0].get_attribute(SpanAttributeKey.CHAT_USAGE)
|
|
assert token_usage["input_tokens"] == 36
|
|
assert token_usage["output_tokens"] == 3344
|
|
assert token_usage["total_tokens"] == 36 + 3344
|
|
assert token_usage["cache_read_input_tokens"] == 139035
|
|
assert token_usage["cache_creation_input_tokens"] == 23554
|
|
|
|
|
|
# ============================================================================
|
|
# SDK MESSAGE PROCESSING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_process_sdk_messages_empty_list():
|
|
assert process_sdk_messages([]) is None
|
|
|
|
|
|
def test_process_sdk_messages_no_user_prompt():
|
|
messages = [
|
|
AssistantMessage(
|
|
content=[TextBlock(text="Hello!")],
|
|
model="claude-sonnet-4-20250514",
|
|
),
|
|
]
|
|
assert process_sdk_messages(messages) is None
|
|
|
|
|
|
def test_process_sdk_messages_simple_conversation():
|
|
messages = [
|
|
UserMessage(content="What is 2 + 2?"),
|
|
AssistantMessage(
|
|
content=[TextBlock(text="The answer is 4.")],
|
|
model="claude-sonnet-4-20250514",
|
|
),
|
|
ResultMessage(
|
|
subtype="success",
|
|
duration_ms=1000,
|
|
duration_api_ms=800,
|
|
is_error=False,
|
|
num_turns=1,
|
|
session_id="test-sdk-session",
|
|
usage={"input_tokens": 100, "output_tokens": 20},
|
|
),
|
|
]
|
|
|
|
trace = process_sdk_messages(messages, "test-sdk-session")
|
|
|
|
assert trace is not None
|
|
spans = list(trace.search_spans())
|
|
|
|
root_span = trace.data.spans[0]
|
|
assert root_span.name == "claude_code_conversation"
|
|
assert root_span.span_type == SpanType.AGENT
|
|
|
|
# LLM span should have conversation context as input in Anthropic format
|
|
llm_spans = [s for s in spans if s.span_type == SpanType.LLM]
|
|
assert len(llm_spans) == 1
|
|
assert llm_spans[0].name == "llm"
|
|
assert llm_spans[0].inputs["model"] == "claude-sonnet-4-20250514"
|
|
assert llm_spans[0].inputs["messages"] == [{"role": "user", "content": "What is 2 + 2?"}]
|
|
assert llm_spans[0].get_attribute(SpanAttributeKey.MESSAGE_FORMAT) == "anthropic"
|
|
|
|
# Output should be in Anthropic response format
|
|
outputs = llm_spans[0].outputs
|
|
assert outputs["type"] == "message"
|
|
assert outputs["role"] == "assistant"
|
|
assert outputs["content"] == [{"type": "text", "text": "The answer is 4."}]
|
|
|
|
# Token usage from ResultMessage should be on the root span and trace level
|
|
token_usage = root_span.get_attribute(SpanAttributeKey.CHAT_USAGE)
|
|
assert token_usage is not None
|
|
assert token_usage["input_tokens"] == 100
|
|
assert token_usage["output_tokens"] == 20
|
|
assert token_usage["total_tokens"] == 120
|
|
|
|
assert trace.info.token_usage is not None
|
|
assert trace.info.token_usage["input_tokens"] == 100
|
|
assert trace.info.token_usage["output_tokens"] == 20
|
|
assert trace.info.token_usage["total_tokens"] == 120
|
|
|
|
# Duration should reflect ResultMessage.duration_ms (1000ms = 1s)
|
|
duration_ns = root_span.end_time_ns - root_span.start_time_ns
|
|
assert abs(duration_ns - 1_000_000_000) < 1_000_000 # within 1ms tolerance
|
|
|
|
assert trace.info.trace_metadata.get("mlflow.trace.session") == "test-sdk-session"
|
|
assert trace.info.request_preview == "What is 2 + 2?"
|
|
assert trace.info.response_preview == "The answer is 4."
|
|
|
|
|
|
def test_process_sdk_messages_multiple_tools():
|
|
messages = [
|
|
UserMessage(content="Read two files"),
|
|
AssistantMessage(
|
|
content=[
|
|
ToolUseBlock(id="tool_1", name="Read", input={"path": "a.py"}),
|
|
ToolUseBlock(id="tool_2", name="Read", input={"path": "b.py"}),
|
|
],
|
|
model="claude-sonnet-4-20250514",
|
|
),
|
|
UserMessage(
|
|
content=[
|
|
ToolResultBlock(tool_use_id="tool_1", content="content of a"),
|
|
ToolResultBlock(tool_use_id="tool_2", content="content of b"),
|
|
],
|
|
tool_use_result={"tool_use_id": "tool_1"},
|
|
),
|
|
AssistantMessage(
|
|
content=[TextBlock(text="Here are the contents.")],
|
|
model="claude-sonnet-4-20250514",
|
|
),
|
|
ResultMessage(
|
|
subtype="success",
|
|
duration_ms=2000,
|
|
duration_api_ms=1500,
|
|
is_error=False,
|
|
num_turns=2,
|
|
session_id="multi-tool-session",
|
|
),
|
|
]
|
|
|
|
trace = process_sdk_messages(messages, "multi-tool-session")
|
|
|
|
assert trace is not None
|
|
spans = list(trace.search_spans())
|
|
|
|
tool_spans = [s for s in spans if s.span_type == SpanType.TOOL]
|
|
assert len(tool_spans) == 2
|
|
assert all(s.name == "tool_Read" for s in tool_spans)
|
|
tool_results = {s.outputs["result"] for s in tool_spans}
|
|
assert tool_results == {"content of a", "content of b"}
|
|
|
|
|
|
def test_process_sdk_messages_cache_tokens():
|
|
messages = [
|
|
UserMessage(content="Hello"),
|
|
AssistantMessage(
|
|
content=[TextBlock(text="Hi!")],
|
|
model="claude-sonnet-4-20250514",
|
|
),
|
|
ResultMessage(
|
|
subtype="success",
|
|
duration_ms=5000,
|
|
duration_api_ms=4000,
|
|
is_error=False,
|
|
num_turns=1,
|
|
session_id="cache-session",
|
|
usage={
|
|
"input_tokens": 36,
|
|
"cache_creation_input_tokens": 23554,
|
|
"cache_read_input_tokens": 139035,
|
|
"output_tokens": 3344,
|
|
},
|
|
),
|
|
]
|
|
|
|
trace = process_sdk_messages(messages, "cache-session")
|
|
|
|
assert trace is not None
|
|
root_span = trace.data.spans[0]
|
|
|
|
# input_tokens is the non-cached input the Anthropic API reports, matching
|
|
# mlflow.anthropic.autolog. Cache fields are exposed as separate keys so
|
|
# consumers can compute cache hit rate without scraping transcripts.
|
|
token_usage = root_span.get_attribute(SpanAttributeKey.CHAT_USAGE)
|
|
assert token_usage["input_tokens"] == 36
|
|
assert token_usage["output_tokens"] == 3344
|
|
assert token_usage["total_tokens"] == 36 + 3344
|
|
assert token_usage["cache_read_input_tokens"] == 139035
|
|
assert token_usage["cache_creation_input_tokens"] == 23554
|
|
|
|
# Trace-level aggregation should match
|
|
assert trace.info.token_usage["input_tokens"] == 36
|
|
assert trace.info.token_usage["output_tokens"] == 3344
|
|
|
|
|
|
# ============================================================================
|
|
# FIND LAST USER MESSAGE INDEX TESTS
|
|
# ============================================================================
|
|
|
|
|
|
def test_find_last_user_message_skips_skill_injection():
|
|
transcript = [
|
|
{"type": "queue-operation"},
|
|
{"type": "queue-operation"},
|
|
# Entry 2: actual user prompt
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Enable tracing on the agent."},
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
},
|
|
# Entry 3: assistant thinking
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "thinking", "thinking": "Let me use the skill."}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:01Z",
|
|
},
|
|
# Entry 4: assistant invokes Skill tool
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_abc123",
|
|
"name": "Skill",
|
|
"input": {"skill": "instrumenting-with-mlflow-tracing"},
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:02Z",
|
|
},
|
|
# Entry 5: tool result with commandName (correctly skipped by toolUseResult check)
|
|
{
|
|
"type": "user",
|
|
"toolUseResult": {
|
|
"success": True,
|
|
"commandName": "instrumenting-with-mlflow-tracing",
|
|
},
|
|
"message": {
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_abc123",
|
|
"content": "Launching skill: instrumenting-with-mlflow-tracing",
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:03Z",
|
|
},
|
|
# Entry 6: skill content injection (BUG: not flagged as tool result)
|
|
{
|
|
"type": "user",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
"Base directory for this skill: /path/to/skill\n\n"
|
|
"# MLflow Tracing Guide\n\n...(full skill content)..."
|
|
),
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:04Z",
|
|
},
|
|
# Entry 7: assistant continues
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "thinking", "thinking": "Now let me implement tracing."}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:05Z",
|
|
},
|
|
# Entry 8: assistant text response
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "I've enabled tracing on the agent."}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:06Z",
|
|
},
|
|
]
|
|
|
|
idx = find_last_user_message_index(transcript)
|
|
|
|
# Should return index 2 (actual user prompt), not 6 (skill injection)
|
|
assert idx == 2
|
|
assert transcript[idx]["message"]["content"] == "Enable tracing on the agent."
|
|
|
|
|
|
def test_find_last_user_message_index_basic():
|
|
transcript = [
|
|
{"type": "queue-operation"},
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "First question"},
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "First answer"}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:01Z",
|
|
},
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Second question"},
|
|
"timestamp": "2025-01-01T00:00:02Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Second answer"}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:03Z",
|
|
},
|
|
]
|
|
|
|
idx = find_last_user_message_index(transcript)
|
|
|
|
assert idx == 3
|
|
assert transcript[idx]["message"]["content"] == "Second question"
|
|
|
|
|
|
def test_find_last_user_message_skips_consecutive_skill_injections():
|
|
transcript = [
|
|
# Entry 0: actual user prompt
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Do the thing."},
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
},
|
|
# Entry 1: assistant invokes first Skill
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_1",
|
|
"name": "Skill",
|
|
"input": {"skill": "skill-one"},
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:01Z",
|
|
},
|
|
# Entry 2: first skill tool result
|
|
{
|
|
"type": "user",
|
|
"toolUseResult": {"success": True, "commandName": "skill-one"},
|
|
"message": {
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_1",
|
|
"content": "Launching skill: skill-one",
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:02Z",
|
|
},
|
|
# Entry 3: first skill content injection
|
|
{
|
|
"type": "user",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Base directory: /skill-one\n# Skill One"}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:03Z",
|
|
},
|
|
# Entry 4: assistant invokes second Skill
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_2",
|
|
"name": "Skill",
|
|
"input": {"skill": "skill-two"},
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:04Z",
|
|
},
|
|
# Entry 5: second skill tool result
|
|
{
|
|
"type": "user",
|
|
"toolUseResult": {"success": True, "commandName": "skill-two"},
|
|
"message": {
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_2",
|
|
"content": "Launching skill: skill-two",
|
|
}
|
|
],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:05Z",
|
|
},
|
|
# Entry 6: second skill content injection
|
|
{
|
|
"type": "user",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Base directory: /skill-two\n# Skill Two"}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:06Z",
|
|
},
|
|
# Entry 7: assistant response
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Done."}],
|
|
},
|
|
"timestamp": "2025-01-01T00:00:07Z",
|
|
},
|
|
]
|
|
|
|
idx = find_last_user_message_index(transcript)
|
|
|
|
# Should skip both skill injections (entries 3 and 6) and return entry 0
|
|
assert idx == 0
|
|
assert transcript[idx]["message"]["content"] == "Do the thing."
|
|
|
|
|
|
def test_process_transcript_captures_claude_code_version(tmp_path):
|
|
transcript = [
|
|
{
|
|
"type": "queue-operation",
|
|
"operation": "dequeue",
|
|
"timestamp": "2025-01-15T09:59:59.000Z",
|
|
"sessionId": "test-version-session",
|
|
},
|
|
{
|
|
"type": "user",
|
|
"version": "2.1.34",
|
|
"message": {"role": "user", "content": "Hello!"},
|
|
"timestamp": "2025-01-15T10:00:00.000Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"version": "2.1.34",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Hi there!"}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:01.000Z",
|
|
},
|
|
]
|
|
|
|
transcript_path = tmp_path / "version_transcript.jsonl"
|
|
transcript_path.write_text("\n".join(json.dumps(entry) for entry in transcript) + "\n")
|
|
trace = process_transcript(str(transcript_path), "test-version-session")
|
|
|
|
assert trace is not None
|
|
assert trace.info.trace_metadata.get(METADATA_KEY_CLAUDE_CODE_VERSION) == "2.1.34"
|
|
|
|
|
|
def test_process_transcript_no_version_field(mock_transcript_file):
|
|
trace = process_transcript(mock_transcript_file, "test-session-no-version")
|
|
|
|
assert trace is not None
|
|
assert METADATA_KEY_CLAUDE_CODE_VERSION not in trace.info.trace_metadata
|
|
|
|
|
|
def test_process_transcript_includes_steer_messages(tmp_path):
|
|
transcript = [
|
|
{
|
|
"type": "user",
|
|
"message": {"role": "user", "content": "Tell me about Python."},
|
|
"timestamp": "2025-01-15T10:00:00.000Z",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Python is a programming language."}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:01.000Z",
|
|
},
|
|
{
|
|
"type": "queue-operation",
|
|
"operation": "enqueue",
|
|
"content": "also tell me about Java",
|
|
"timestamp": "2025-01-15T10:00:02.000Z",
|
|
"sessionId": "test-steer-session",
|
|
},
|
|
{
|
|
"type": "queue-operation",
|
|
"operation": "remove",
|
|
"timestamp": "2025-01-15T10:00:03.000Z",
|
|
"sessionId": "test-steer-session",
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Java is also a programming language."}],
|
|
},
|
|
"timestamp": "2025-01-15T10:00:04.000Z",
|
|
},
|
|
]
|
|
|
|
transcript_path = tmp_path / "steer_transcript.jsonl"
|
|
transcript_path.write_text("\n".join(json.dumps(entry) for entry in transcript) + "\n")
|
|
trace = process_transcript(str(transcript_path), "test-steer-session")
|
|
assert trace is not None
|
|
|
|
spans = list(trace.search_spans())
|
|
llm_spans = [s for s in spans if s.span_type == SpanType.LLM]
|
|
assert len(llm_spans) == 2
|
|
|
|
# The second LLM span should include the steer message in its inputs
|
|
second_llm = llm_spans[1]
|
|
input_messages = second_llm.inputs["messages"]
|
|
steer_messages = [m for m in input_messages if m.get("content") == "also tell me about Java"]
|
|
assert len(steer_messages) == 1
|
|
assert steer_messages[0]["role"] == "user"
|