diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 6681cc6..ec5159a 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -17,25 +17,29 @@ import threading import time import hashlib -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -# --- Langfuse import (fail-open) --- + +# ----------------- Langfuse import (fail-open) ----------------- try: from langfuse import Langfuse, propagate_attributes from opentelemetry import trace as otel_trace_api except Exception: sys.exit(0) -# --- Paths --- + +# ----------------- Paths ----------------- STATE_DIR = Path.home() / ".claude" / "state" LOG_FILE = STATE_DIR / "langfuse_hook.log" STATE_FILE = STATE_DIR / "langfuse_state.json" LOCK_FILE = STATE_DIR / "langfuse_state.lock" + +# ----------------- Configuration ----------------- def _opt(name: str) -> str: """Read a plugin userConfig value (CLAUDE_PLUGIN_OPTION_) with a fallback to a plain env var.""" return os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name}") or os.environ.get(name) or "" @@ -48,6 +52,43 @@ def _opt(name: str) -> str: except ValueError: MAX_CHARS = 20000 +# Bound for unresolved task notifications kept in the state file between runs. +MAX_PENDING_TASK_NOTIFICATIONS = 50 + +@dataclass +class LangfuseConfig: + public_key: str + secret_key: str + host: str + user_id: Optional[str] + +def get_langfuse_config() -> Optional[LangfuseConfig]: + public_key = _opt("LANGFUSE_PUBLIC_KEY") or _opt("CC_LANGFUSE_PUBLIC_KEY") + secret_key = _opt("LANGFUSE_SECRET_KEY") or _opt("CC_LANGFUSE_SECRET_KEY") + host = _opt("LANGFUSE_BASE_URL") or _opt("CC_LANGFUSE_BASE_URL") or "https://us.cloud.langfuse.com" + user_id = _opt("LANGFUSE_USER_ID") or _opt("CC_LANGFUSE_USER_ID") or None + + if not public_key or not secret_key: + return None + + return LangfuseConfig( + public_key=public_key, + secret_key=secret_key, + host=host, + user_id=user_id, + ) + +def create_langfuse_client(config: LangfuseConfig) -> Optional[Langfuse]: + try: + return Langfuse( + public_key=config.public_key, + secret_key=config.secret_key, + host=config.host, + ) + except Exception: + return None + + # ----------------- Logging ----------------- _logger: Optional[logging.Logger] = None @@ -89,7 +130,75 @@ def info(msg: str) -> None: except Exception: pass -# ----------------- State locking (best-effort) ----------------- + +# ----------------- Hook payload ----------------- +def read_hook_payload() -> Dict[str, Any]: + """ + Claude Code hooks pass a JSON payload on stdin. + This script tolerates missing/empty stdin by returning {}. + """ + try: + data = sys.stdin.read() + debug(f"stdin received {len(data)} chars") + if not data.strip(): + return {} + parsed = json.loads(data) + if isinstance(parsed, dict): + debug(f"payload top-level keys: {sorted(parsed.keys())}") + return parsed + debug(f"payload is {type(parsed).__name__}, expected object; exiting.") + return {} + except Exception as e: + debug(f"read_hook_payload exception: {e!r}") + return {} + +def extract_session_id_and_transcript_path(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path]]: + """ + Tries a few plausible field names; exact keys can vary across hook types/versions. + Prefer structured values from stdin over heuristics. + """ + session_id = ( + payload.get("sessionId") + or payload.get("session_id") + or payload.get("session", {}).get("id") + ) + + transcript_path_raw = ( + payload.get("transcriptPath") + or payload.get("transcript_path") + or payload.get("transcript", {}).get("path") + ) + + if transcript_path_raw: + try: + transcript_path = Path(transcript_path_raw).expanduser().resolve() + except Exception: + transcript_path = None + else: + transcript_path = None + + return session_id, transcript_path + +def get_session_id_and_transcript_path(payload: Dict[str, Any]) -> Optional[Tuple[str, Path]]: + session_id, transcript_path = extract_session_id_and_transcript_path(payload) + + if not session_id or not transcript_path: + # No structured payload; fail open (do not guess). + debug("Missing session_id or transcript_path from hook payload; exiting.") + return None + + if not transcript_path.exists(): + debug(f"Transcript path does not exist: {transcript_path}") + return None + + return session_id, transcript_path + +def is_session_end_hook_payload(payload: Dict[str, Any]) -> bool: + hook_event_name = payload.get("hook_event_name") or payload.get("hookEventName") + return hook_event_name == "SessionEnd" + + +# ----------------- State file concurrency control ----------------- class FileLock: def __init__(self, path: Path, timeout_s: float = 2.0): self.path = path @@ -138,7 +247,9 @@ def __exit__(self, exc_type, exc, tb): except Exception: pass -def load_state() -> Dict[str, Any]: + +# ----------------- State file reading and writing ----------------- +def load_hook_state() -> Dict[str, Any]: try: if not STATE_FILE.exists(): return {} @@ -146,7 +257,48 @@ def load_state() -> Dict[str, Any]: except Exception: return {} -def save_state(state: Dict[str, Any]) -> None: +def get_session_state_key(session_id: str, transcript_path: str) -> str: + # stable key even if session_id collides + raw = f"{session_id}::{transcript_path}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + +@dataclass +class SessionState: + offset: int = 0 # Last byte read from the transcript file. + buffer: str = "" # Partial JSONL line kept between hook runs. + turn_count: int = 0 # Turns already emitted for this session. + pending_agent_turns: List[Dict[str, Any]] = field(default_factory=list) + # Task-notification rows whose tool_use_id could not be resolved yet + # (task-id-only and the subagent meta.json not on disk); retried each run. + pending_task_notifications: List[Dict[str, Any]] = field(default_factory=list) + +def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: + s = global_state.get(key, {}) + pending_agent_turns = s.get("pending_agent_turns") + if not isinstance(pending_agent_turns, list): + pending_agent_turns = [] + pending_task_notifications = s.get("pending_task_notifications") + if not isinstance(pending_task_notifications, list): + pending_task_notifications = [] + return SessionState( + offset=int(s.get("offset", 0)), + buffer=str(s.get("buffer", "")), + turn_count=int(s.get("turn_count", 0)), + pending_agent_turns=pending_agent_turns, + pending_task_notifications=pending_task_notifications, + ) + +def update_session_state(global_state: Dict[str, Any], key: str, session_state: SessionState) -> None: + global_state[key] = { + "offset": session_state.offset, + "buffer": session_state.buffer, + "turn_count": session_state.turn_count, + "pending_agent_turns": session_state.pending_agent_turns or [], + "pending_task_notifications": session_state.pending_task_notifications or [], + "updated": datetime.now(timezone.utc).isoformat(), + } + +def save_hook_state(state: Dict[str, Any]) -> None: try: # Drop session entries older than 30 days to keep the file bounded. cutoff = datetime.now(timezone.utc) - timedelta(days=30) @@ -168,60 +320,14 @@ def save_state(state: Dict[str, Any]) -> None: tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") os.replace(tmp, STATE_FILE) except Exception as e: - debug(f"save_state failed: {e}") + debug(f"save_hook_state failed: {e}") -def state_key(session_id: str, transcript_path: str) -> str: - # stable key even if session_id collides - raw = f"{session_id}::{transcript_path}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() +def save_session_state(global_state: Dict[str, Any], key: str, session_state: SessionState) -> None: + update_session_state(global_state, key, session_state) + save_hook_state(global_state) -# ----------------- Hook payload ----------------- -def read_hook_payload() -> Dict[str, Any]: - """ - Claude Code hooks pass a JSON payload on stdin. - This script tolerates missing/empty stdin by returning {}. - """ - try: - data = sys.stdin.read() - debug(f"stdin received {len(data)} chars") - if not data.strip(): - return {} - parsed = json.loads(data) - if isinstance(parsed, dict): - debug(f"payload top-level keys: {sorted(parsed.keys())}") - return parsed - except Exception as e: - debug(f"read_hook_payload exception: {e!r}") - return {} -def extract_session_id_and_transcript_path(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path]]: - """ - Tries a few plausible field names; exact keys can vary across hook types/versions. - Prefer structured values from stdin over heuristics. - """ - session_id = ( - payload.get("sessionId") - or payload.get("session_id") - or payload.get("session", {}).get("id") - ) - - transcript_path_raw = ( - payload.get("transcriptPath") - or payload.get("transcript_path") - or payload.get("transcript", {}).get("path") - ) - - if transcript_path_raw: - try: - transcript_path = Path(transcript_path_raw).expanduser().resolve() - except Exception: - transcript_path = None - else: - transcript_path = None - - return session_id, transcript_path - -# ----------------- Transcript parsing helpers ----------------- +# ----------------- Transcript row parsing ----------------- def get_content_from_row(row: Dict[str, Any]) -> Any: if not isinstance(row, dict): return None @@ -244,55 +350,16 @@ def get_user_or_assistant_role_from_row(row: Dict[str, Any]) -> Optional[str]: return role return None -def is_tool_result(row: Dict[str, Any]) -> bool: - role = get_user_or_assistant_role_from_row(row) - if role != "user": - return False - content = get_content_from_row(row) - if isinstance(content, list): - return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content) - return False - -def get_tool_result_blocks(content: Any) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - if isinstance(content, list): - for x in content: - if isinstance(x, dict) and x.get("type") == "tool_result": - out.append(x) - return out - -def get_tool_use_blocks(content: Any) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - if isinstance(content, list): - for x in content: - if isinstance(x, dict) and x.get("type") == "tool_use": - out.append(x) - return out - -def extract_text(content: Any) -> str: - if isinstance(content, str): - return content - if isinstance(content, list): - parts: List[str] = [] - for x in content: - if isinstance(x, dict) and x.get("type") == "text": - parts.append(x.get("text", "")) - elif isinstance(x, str): - parts.append(x) - return "\n".join([p for p in parts if p]) - return "" - -def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]: - if s is None: - return "", {"truncated": False, "orig_len": 0} - orig_len = len(s) - if orig_len <= max_chars: - return s, {"truncated": False, "orig_len": orig_len} - head = s[:max_chars] - return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()} +def get_message_id(row: Dict[str, Any]) -> Optional[str]: + m = row.get("message") + if isinstance(m, dict): + mid = m.get("id") + if isinstance(mid, str) and mid: + return mid + return None -def get_model(msg: Dict[str, Any]) -> str: - m = msg.get("message") +def get_model(row: Dict[str, Any]) -> str: + m = row.get("message") if isinstance(m, dict): return m.get("model") or "claude" return "claude" @@ -317,14 +384,6 @@ def get_usage_details_from_row(row: Dict[str, Any]) -> Optional[Dict[str, int]]: details[dst] = v return details or None -def get_message_id(msg: Dict[str, Any]) -> Optional[str]: - m = msg.get("message") - if isinstance(m, dict): - mid = m.get("id") - if isinstance(mid, str) and mid: - return mid - return None - def parse_timestamp(value: Any) -> Optional[datetime]: """Parse a Claude Code jsonl row timestamp (ISO 8601 with trailing Z).""" if isinstance(value, dict): @@ -336,65 +395,91 @@ def parse_timestamp(value: Any) -> Optional[datetime]: except Exception: return None -# ----------------- Incremental reader ----------------- -@dataclass -class SessionState: - offset: int = 0 # Last byte read from the transcript file. - buffer: str = "" # Partial JSONL line kept between hook runs. - turn_count: int = 0 # Turns already emitted for this session. +def extract_text_from_content(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for x in content: + if isinstance(x, dict) and x.get("type") == "text": + parts.append(x.get("text", "")) + elif isinstance(x, str): + parts.append(x) + return "\n".join([p for p in parts if p]) + return "" -def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState: - s = global_state.get(key, {}) - return SessionState( - offset=int(s.get("offset", 0)), - buffer=str(s.get("buffer", "")), - turn_count=int(s.get("turn_count", 0)), - ) +def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]: + if s is None: + return "", {"truncated": False, "orig_len": 0} + orig_len = len(s) + if orig_len <= max_chars: + return s, {"truncated": False, "orig_len": orig_len} + head = s[:max_chars] + return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()} + +def get_tool_use_blocks(content: Any) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + if isinstance(content, list): + for x in content: + if isinstance(x, dict) and x.get("type") == "tool_use": + out.append(x) + return out + +def get_tool_result_blocks(content: Any) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + if isinstance(content, list): + for x in content: + if isinstance(x, dict) and x.get("type") == "tool_result": + out.append(x) + return out + +def is_tool_result(row: Dict[str, Any]) -> bool: + role = get_user_or_assistant_role_from_row(row) + if role != "user": + return False + content = get_content_from_row(row) + if isinstance(content, list): + return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content) + return False -def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None: - global_state[key] = { - "offset": ss.offset, - "buffer": ss.buffer, - "turn_count": ss.turn_count, - "updated": datetime.now(timezone.utc).isoformat(), - } -def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: +# ----------------- Incremental transcript reading ----------------- +def read_new_jsonl(transcript_path: Path, session_state: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: """ - Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line. - Returns parsed JSON lines (best-effort) and updated state. + Reads only new bytes since session_state.offset. Keeps session_state.buffer for partial last line. + Returns parsed JSON lines and updated state. """ if not transcript_path.exists(): - return [], ss + return [], session_state try: file_size = transcript_path.stat().st_size - if file_size < ss.offset: + if file_size < session_state.offset: # Transcript was rotated or truncated — restart from the beginning. - debug(f"transcript shrank ({file_size} < {ss.offset}); restarting") - ss.offset = 0 - ss.buffer = "" + debug(f"transcript shrank ({file_size} < {session_state.offset}); restarting") + session_state.offset = 0 + session_state.buffer = "" with open(transcript_path, "rb") as f: - f.seek(ss.offset) + f.seek(session_state.offset) chunk = f.read() new_offset = f.tell() except Exception as e: debug(f"read_new_jsonl failed: {e}") - return [], ss + return [], session_state if not chunk: - return [], ss + return [], session_state try: text = chunk.decode("utf-8", errors="replace") except Exception: text = chunk.decode(errors="replace") - combined = ss.buffer + text + combined = session_state.buffer + text lines = combined.split("\n") # last element may be incomplete - ss.buffer = lines[-1] - ss.offset = new_offset + session_state.buffer = lines[-1] + session_state.offset = new_offset msgs: List[Dict[str, Any]] = [] for line in lines[:-1]: @@ -406,7 +491,8 @@ def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[s except Exception: continue - return msgs, ss + return msgs, session_state + # ----------------- Turn assembly ----------------- @dataclass @@ -414,9 +500,316 @@ class Turn: user_msg: Dict[str, Any] assistant_msgs: List[Dict[str, Any]] tool_results_by_id: Dict[str, Any] + tool_use_timestamps_by_id: Dict[str, Any] # Injected context (e.g. skill instructions) keyed by the tool_use id it # belongs to, taken from isMeta rows carrying sourceToolUseID. injected_by_tool_id: Dict[str, str] + rows: List[Dict[str, Any]] + +@dataclass +class TurnAssemblyState: + current_turn_user_row: Optional[Dict[str, Any]] = None + assistant_message_ids: List[str] = field(default_factory=list) + assistant_rows_by_message_id: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + tool_results_by_id: Dict[str, Any] = field(default_factory=dict) + tool_use_timestamps_by_id: Dict[str, Any] = field(default_factory=dict) + injected_by_tool_id: Dict[str, str] = field(default_factory=dict) + current_rows: List[Dict[str, Any]] = field(default_factory=list) + + +def _extract_xml_tag_value(text: str, tag: str) -> Optional[str]: + start = f"<{tag}>" + end = f"" + i = text.find(start) + if i < 0: + return None + j = text.find(end, i + len(start)) + if j < 0: + return None + return text[i + len(start):j] + +def is_task_notification_row(row: Dict[str, Any]) -> bool: + origin = row.get("origin") + if isinstance(origin, dict) and origin.get("kind") == "task-notification": + return True + + notification_text = extract_text_from_content(get_content_from_row(row)).lstrip() + return notification_text.startswith("") + +def get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str]: + notification_text = extract_text_from_content(get_content_from_row(row)) + tool_use_id = _extract_xml_tag_value(notification_text, "tool-use-id") + return tool_use_id.strip() if isinstance(tool_use_id, str) and tool_use_id.strip() else None + +def get_task_id_from_task_notification(row: Dict[str, Any]) -> Optional[str]: + notification_text = extract_text_from_content(get_content_from_row(row)) + task_id = _extract_xml_tag_value(notification_text, "task-id") + return task_id.strip() if isinstance(task_id, str) and task_id.strip() else None + +def get_tool_use_id_for_task_notification( + row: Dict[str, Any], + task_id_to_tool_use_id: Optional[Dict[str, str]] = None, +) -> Optional[str]: + if not is_task_notification_row(row): + return None + + tool_use_id = get_tool_use_id_from_task_notification(row) + if tool_use_id: + return tool_use_id + + task_id = get_task_id_from_task_notification(row) + if task_id and task_id_to_tool_use_id: + return task_id_to_tool_use_id.get(task_id) + return None + +def get_result_from_task_notification(row: Dict[str, Any]) -> str: + notification_text = extract_text_from_content(get_content_from_row(row)) + result = _extract_xml_tag_value(notification_text, "result") + return result if result is not None else notification_text + +def _find_pending_agent_turn( + session_state: SessionState, + tool_use_id: str, +) -> Optional[Dict[str, Any]]: + for pending_turn in session_state.pending_agent_turns: + if not isinstance(pending_turn, dict): + continue + if not isinstance(pending_turn.get("rows"), list): + continue + pending_tool_use_ids = pending_turn.get("pending_tool_use_ids") + resolved_tool_use_ids = pending_turn.get("resolved_tool_use_ids") + # Notifications can arrive more than once per tool_use_id, so ids that + # already received one keep matching until the whole turn resolves. + if isinstance(pending_tool_use_ids, list) and tool_use_id in pending_tool_use_ids: + return pending_turn + if isinstance(resolved_tool_use_ids, list) and tool_use_id in resolved_tool_use_ids: + return pending_turn + return None + +def resolve_deferred_agent_turns( + rows: List[Dict[str, Any]], + session_state: SessionState, + task_id_to_tool_use_id: Optional[Dict[str, str]] = None, +) -> Tuple[List[List[Dict[str, Any]]], List[Dict[str, Any]]]: + """Move task-notification rows from the batch to their deferred turns. + + Deferred rows are never spliced into the batch (a user row mid-batch would + cut the current turn in half); resolved turns are returned for isolated + assembly. Notifications matching a tool_use in the batch stay there, and + ones that cannot be attributed yet (task-id-only, subagent meta.json not + on disk) are stashed in the session state and retried on later runs + instead of being swallowed by the turn assembly. + """ + remaining_rows: List[Dict[str, Any]] = [] + stashed_notifications: List[Dict[str, Any]] = [] + + def route_to_pending_turn(pending_turn: Dict[str, Any], row: Dict[str, Any], tool_use_id: str) -> None: + pending_turn["rows"].append(row) + pending_tool_use_ids = pending_turn.get("pending_tool_use_ids") + if isinstance(pending_tool_use_ids, list) and tool_use_id in pending_tool_use_ids: + pending_tool_use_ids.remove(tool_use_id) + pending_turn.setdefault("resolved_tool_use_ids", []).append(tool_use_id) + + # Retry stashed notifications from earlier runs first (they are older than + # anything in the batch); their task-id may resolve now. + for row in session_state.pending_task_notifications: + tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) + if tool_use_id is None: + stashed_notifications.append(row) + continue + pending_turn = _find_pending_agent_turn(session_state, tool_use_id) + if pending_turn is None: + debug(f"Dropping stashed task notification for {tool_use_id}: no deferred turn waits for it") + continue + route_to_pending_turn(pending_turn, row, tool_use_id) + + for row in rows: + if not is_task_notification_row(row): + remaining_rows.append(row) + continue + tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) + if tool_use_id is None: + stashed_notifications.append(row) + continue + pending_turn = _find_pending_agent_turn(session_state, tool_use_id) + if pending_turn is None: + remaining_rows.append(row) + continue + route_to_pending_turn(pending_turn, row, tool_use_id) + + session_state.pending_task_notifications = stashed_notifications[-MAX_PENDING_TASK_NOTIFICATIONS:] + + # Pop fully resolved turns in deferral (i.e. chronological) order. + resolved_turn_row_lists: List[List[Dict[str, Any]]] = [] + still_pending: List[Dict[str, Any]] = [] + for pending_turn in session_state.pending_agent_turns: + if not isinstance(pending_turn, dict) or not isinstance(pending_turn.get("rows"), list): + continue + if pending_turn.get("pending_tool_use_ids"): + still_pending.append(pending_turn) + continue + resolved_turn_row_lists.append(pending_turn["rows"]) + session_state.pending_agent_turns = still_pending + + return resolved_turn_row_lists, remaining_rows + +def pop_all_deferred_agent_turn_row_lists( + session_state: SessionState, +) -> List[List[Dict[str, Any]]]: + row_lists: List[List[Dict[str, Any]]] = [] + for pending_turn in session_state.pending_agent_turns: + if not isinstance(pending_turn, dict): + continue + rows = pending_turn.get("rows") + if isinstance(rows, list) and rows: + row_lists.append(rows) + session_state.pending_agent_turns = [] + return row_lists + +def get_tool_result_text(tool_result_entry: Any) -> str: + if not isinstance(tool_result_entry, dict): + return "" + tool_result_content = tool_result_entry.get("content") + if isinstance(tool_result_content, str): + return tool_result_content + return json.dumps(tool_result_content, ensure_ascii=False) + +def get_async_launch_flag_from_row(row: Dict[str, Any]) -> Optional[bool]: + """Read the structured async marker Claude Code puts on tool_result rows. + + Returns None when the row carries no toolUseResult (older Claude Code + versions), so callers can fall back to the launch-text heuristic. + """ + tool_use_result = row.get("toolUseResult") + if not isinstance(tool_use_result, dict): + return None + return tool_use_result.get("status") == "async_launched" or tool_use_result.get("isAsync") is True + +def is_async_agent_launch_result(tool_result_entry: Any) -> bool: + if not isinstance(tool_result_entry, dict): + return False + # Prefer the structured toolUseResult marker: launch-text matching also + # fires on tool results that merely quote it (e.g. reading this file). + is_async_launch = tool_result_entry.get("is_async_launch") + if is_async_launch is not None: + return bool(is_async_launch) + tool_result_text = get_tool_result_text(tool_result_entry) + return ( + "Async agent launched successfully" in tool_result_text + or ( + "agentId:" in tool_result_text + and "output_file:" in tool_result_text + and "You will be notified automatically" in tool_result_text + ) + ) + +def get_pending_agent_tool_use_ids(turn: Turn) -> List[str]: + tool_use_ids: List[str] = [] + for assistant_message in turn.assistant_msgs: + for tool_use_block in get_tool_use_blocks(get_content_from_row(assistant_message)): + if tool_use_block.get("name") not in ("Agent", "Task"): + continue + tool_use_id = str(tool_use_block.get("id") or "") + if not tool_use_id: + continue + tool_result_entry = turn.tool_results_by_id.get(tool_use_id) + if isinstance(tool_result_entry, dict) and tool_result_entry.get("final_content") is not None: + continue + # Defer only explicit async launches: sync agents also write a + # subagent transcript but never notify, so deferring on transcript + # existence would strand their turns. + if is_async_agent_launch_result(tool_result_entry): + tool_use_ids.append(tool_use_id) + return tool_use_ids + +def get_turns_to_emit( + turns: List[Turn], + session_state: SessionState, + *, + flush_deferred_agent_turns: bool = False, +) -> List[Turn]: + turns_to_emit: List[Turn] = [] + for turn in turns: + pending_agent_tool_use_ids = get_pending_agent_tool_use_ids(turn) + if pending_agent_tool_use_ids: + if flush_deferred_agent_turns: + debug(f"Emitting async agent turn without task notification: {pending_agent_tool_use_ids}") + turns_to_emit.append(turn) + continue + session_state.pending_agent_turns.append({ + "pending_tool_use_ids": pending_agent_tool_use_ids, + "rows": turn.rows, + }) + debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") + continue + turns_to_emit.append(turn) + return turns_to_emit + + +def add_injected_context_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: + # Injected user rows (slash-command expansions, caveats, skill instructions) + # carry isMeta=true. They are not real prompts, so they must not start turns. + if not row.get("isMeta"): + return False + + # Skill invocations link their injected instructions to the originating + # tool_use via sourceToolUseID; keep the text so emit can optionally attach + # it to that tool span. + source_tool_use_id = row.get("sourceToolUseID") + if source_tool_use_id: + text = extract_text_from_content(get_content_from_row(row)) + if text: + state.injected_by_tool_id[str(source_tool_use_id)] = text + state.current_rows.append(row) + return True + +def add_tool_result_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: + # tool_result rows show up as role=user with content blocks of type tool_result. + if not is_tool_result(row): + return False + + state.current_rows.append(row) + row_timestamp = row.get("timestamp") + is_async_launch = get_async_launch_flag_from_row(row) + for tool_result_block in get_tool_result_blocks(get_content_from_row(row)): + tool_use_id = tool_result_block.get("tool_use_id") + if tool_use_id: + tool_result_entry: Dict[str, Any] = { + "content": tool_result_block.get("content"), + "timestamp": row_timestamp, + } + if is_async_launch is not None: + tool_result_entry["is_async_launch"] = is_async_launch + state.tool_results_by_id[str(tool_use_id)] = tool_result_entry + return True + +def add_task_notification_row( + row: Dict[str, Any], + state: TurnAssemblyState, + task_id_to_tool_use_id: Optional[Dict[str, str]] = None, +) -> bool: + if not is_task_notification_row(row): + return False + + if state.current_turn_user_row is None: + return True + + tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) + if not tool_use_id: + state.current_rows.append(row) + return True + + existing_result = state.tool_results_by_id.get(tool_use_id) + if isinstance(existing_result, dict): + existing_result["final_content"] = get_result_from_task_notification(row) + existing_result["final_timestamp"] = row.get("timestamp") + else: + state.tool_results_by_id[tool_use_id] = { + "content": get_result_from_task_notification(row), + "timestamp": row.get("timestamp"), + } + state.current_rows.append(row) + return True def merge_assistant_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: """ @@ -444,8 +837,64 @@ def merge_assistant_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: base["message"] = merged_message return base +def build_turn_from_state(state: TurnAssemblyState) -> Optional[Turn]: + if state.current_turn_user_row is None: + return None + if not state.assistant_rows_by_message_id: + return None + + # Rebuild one assistant message per message.id, in the order the ids + # first appeared. assistant_rows_by_message_id[message_id] holds all raw + # rows that shared that id; merge_assistant_rows concatenates their content + # blocks into one. + merged_assistant_rows: List[Dict[str, Any]] = [] + for message_id in state.assistant_message_ids: + rows_for_id = state.assistant_rows_by_message_id.get(message_id) + if not rows_for_id: + continue + merged_assistant_rows.append(merge_assistant_rows(rows_for_id)) + + return Turn( + user_msg=state.current_turn_user_row, + assistant_msgs=merged_assistant_rows, + tool_results_by_id=dict(state.tool_results_by_id), + tool_use_timestamps_by_id=dict(state.tool_use_timestamps_by_id), + injected_by_tool_id=dict(state.injected_by_tool_id), + rows=list(state.current_rows), + ) + +def start_new_turn(row: Dict[str, Any], state: TurnAssemblyState) -> None: + state.current_turn_user_row = row + state.assistant_message_ids = [] + state.assistant_rows_by_message_id = {} + state.tool_results_by_id = {} + state.tool_use_timestamps_by_id = {} + state.injected_by_tool_id = {} + state.current_rows = [row] + + +def add_assistant_row(row: Dict[str, Any], state: TurnAssemblyState) -> None: + if state.current_turn_user_row is None: + # Ignore assistant rows until we see a user message. + return + + message_id = get_message_id(row) or f"noid:{len(state.assistant_message_ids)}" + if message_id not in state.assistant_rows_by_message_id: + state.assistant_message_ids.append(message_id) + state.assistant_rows_by_message_id[message_id] = [] + state.assistant_rows_by_message_id[message_id].append(row) + + for tool_use_block in get_tool_use_blocks(get_content_from_row(row)): + tool_use_id = tool_use_block.get("id") + if tool_use_id: + state.tool_use_timestamps_by_id.setdefault(str(tool_use_id), row.get("timestamp")) + state.current_rows.append(row) -def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]: + +def build_turns( + rows: List[Dict[str, Any]], + task_id_to_tool_use_id: Optional[Dict[str, str]] = None, +) -> List[Turn]: """ Groups incremental transcript rows into turns: user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved) @@ -454,100 +903,132 @@ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]: - tool results dedupe by tool_use_id (latest wins) """ turns: List[Turn] = [] - current_turn_user_row: Optional[Dict[str, Any]] = None + state = TurnAssemblyState() - # assistant messages for current turn: - assistant_message_ids: List[str] = [] # message ids in order of first appearance (or synthetic) - assistant_rows_by_message_id: Dict[str, List[Dict[str, Any]]] = {} # id -> all rows (merged at flush) - - tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content - injected_by_tool_id: Dict[str, str] = {} # tool_use_id -> injected text (skill instructions) - - def flush_turn(): - nonlocal current_turn_user_row, assistant_message_ids, assistant_rows_by_message_id, tool_results_by_id, injected_by_tool_id, turns - if current_turn_user_row is None: - return - if not assistant_rows_by_message_id: - return - # Rebuild one assistant message per message.id, in the order the ids - # first appeared. assistant_rows_by_message_id[message_id] holds all raw rows that shared that - # id; merge_assistant_rows concatenates their content blocks into one. - merged_assistant_rows: List[Dict[str, Any]] = [] - for message_id in assistant_message_ids: - rows_for_id = assistant_rows_by_message_id.get(message_id) - if not rows_for_id: - continue - merged_assistant_rows.append(merge_assistant_rows(rows_for_id)) - turns.append(Turn( - user_msg=current_turn_user_row, - assistant_msgs=merged_assistant_rows, - tool_results_by_id=dict(tool_results_by_id), - injected_by_tool_id=dict(injected_by_tool_id), - )) - - for row in messages: - # Injected user rows (slash-command expansions, caveats, skill instructions) - # carry isMeta=true. They are not real prompts — treating them as turn starts - # creates phantom turns and prematurely flushes the real one. - if row.get("isMeta"): - # Skill invocations link their injected instructions to the originating - # tool_use via sourceToolUseID; keep the text so emit can optionally - # attach it to that tool span. - src = row.get("sourceToolUseID") - if src: - txt = extract_text(get_content_from_row(row)) - if txt: - injected_by_tool_id[str(src)] = txt + for row in rows: + if add_injected_context_row(row, state): continue - role = get_user_or_assistant_role_from_row(row) + if add_tool_result_row(row, state): + continue - # tool_result rows show up as role=user with content blocks of type tool_result - if is_tool_result(row): - row_ts = row.get("timestamp") - for tr in get_tool_result_blocks(get_content_from_row(row)): - tid = tr.get("tool_use_id") - if tid: - tool_results_by_id[str(tid)] = {"content": tr.get("content"), "timestamp": row_ts} + if add_task_notification_row(row, state, task_id_to_tool_use_id): continue + role = get_user_or_assistant_role_from_row(row) + if role == "user": - # new user message -> finalize previous turn - flush_turn() - - # start a new turn - current_turn_user_row = row - assistant_message_ids = [] - assistant_rows_by_message_id = {} - tool_results_by_id = {} - injected_by_tool_id = {} + turn = build_turn_from_state(state) + if turn is not None: + turns.append(turn) + + start_new_turn(row, state) continue if role == "assistant": - if current_turn_user_row is None: - # ignore assistant rows until we see a user message - continue - - message_id = get_message_id(row) or f"noid:{len(assistant_message_ids)}" - if message_id not in assistant_rows_by_message_id: - assistant_message_ids.append(message_id) - assistant_rows_by_message_id[message_id] = [] - assistant_rows_by_message_id[message_id].append(row) + add_assistant_row(row, state) continue # ignore unknown rows - # flush last - flush_turn() + turn = build_turn_from_state(state) + if turn is not None: + turns.append(turn) return turns + +def get_new_turns_from_transcript( + transcript_path: Path, + session_state: SessionState, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, + *, + flush_deferred_agent_turns: bool = False, +) -> Tuple[List[Turn], SessionState]: + rows, session_state = read_new_jsonl(transcript_path, session_state) + task_id_to_tool_use_id = get_task_id_to_tool_use_id(subagent_transcripts_by_tool_use_id) + + deferred_turn_row_lists, rows = resolve_deferred_agent_turns(rows, session_state, task_id_to_tool_use_id) + + if flush_deferred_agent_turns and session_state.pending_agent_turns: + flushed_row_lists = pop_all_deferred_agent_turn_row_lists(session_state) + if flushed_row_lists: + debug(f"Flushing {len(flushed_row_lists)} deferred agent turn(s) without task notification") + deferred_turn_row_lists = deferred_turn_row_lists + flushed_row_lists + + if flush_deferred_agent_turns and session_state.pending_task_notifications: + debug(f"Dropping {len(session_state.pending_task_notifications)} unresolved task notification(s) at session end") + session_state.pending_task_notifications = [] + + # Each deferred row list is a complete turn from an earlier hook run, so + # it is rebuilt in isolation and emitted before the current batch (its + # rows are always chronologically older than anything in the batch). + turns: List[Turn] = [] + for deferred_turn_rows in deferred_turn_row_lists: + turns.extend(build_turns(deferred_turn_rows, task_id_to_tool_use_id)) + if rows: + turns.extend(build_turns(rows, task_id_to_tool_use_id)) + + return turns, session_state + +def get_subagent_transcripts_by_tool_use_id(transcript_path: Path) -> Dict[str, Dict[str, Any]]: + """Map launching Agent/Task tool_use ids to their subagent transcripts.""" + subagent_dir = transcript_path.with_suffix("") / "subagents" + if not subagent_dir.is_dir(): + return {} + + subagent_transcripts_by_tool_use_id: Dict[str, Dict[str, Any]] = {} + for meta_path in subagent_dir.glob("*.meta.json"): + try: + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + except Exception: + continue + + tool_use_id = metadata.get("toolUseId") + if not isinstance(tool_use_id, str) or not tool_use_id: + continue + + jsonl_path = meta_path.with_name(meta_path.name[: -len(".meta.json")] + ".jsonl") + if not jsonl_path.exists(): + continue + + agent_id = meta_path.name[: -len(".meta.json")] + if agent_id.startswith("agent-"): + agent_id = agent_id[len("agent-"):] + + subagent_transcripts_by_tool_use_id[tool_use_id] = { + "path": jsonl_path, + "agent_id": agent_id, + "agent_type": metadata.get("agentType"), + "description": metadata.get("description"), + } + return subagent_transcripts_by_tool_use_id + +def get_task_id_to_tool_use_id( + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]], +) -> Dict[str, str]: + task_id_to_tool_use_id: Dict[str, str] = {} + if not subagent_transcripts_by_tool_use_id: + return task_id_to_tool_use_id + + for tool_use_id, subagent in subagent_transcripts_by_tool_use_id.items(): + agent_id = subagent.get("agent_id") + if isinstance(agent_id, str) and agent_id: + task_id_to_tool_use_id[agent_id] = tool_use_id + return task_id_to_tool_use_id + + # ----------------- Langfuse emit ----------------- -def _to_ns(ts: Optional[datetime]) -> Optional[int]: + +# ---- Low-level Langfuse helpers ---- +def to_otel_nanoseconds(ts: Optional[datetime]) -> Optional[int]: """Convert a datetime to OTel-style nanoseconds since epoch.""" if ts is None: return None return int(ts.timestamp() * 1_000_000_000) +def _get_latest_timestamp(*timestamps: Optional[datetime]) -> Optional[datetime]: + present_timestamps = [timestamp for timestamp in timestamps if timestamp is not None] + return max(present_timestamps) if present_timestamps else None def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, start_time: Optional[datetime], @@ -574,7 +1055,7 @@ def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, f"_create_observation_from_otel_span. This hook targets SDK 4.x; " f"pin with `pip install \"langfuse>=4.0,<5\"` or update the hook script." ) - start_ns = _to_ns(start_time) + start_ns = to_otel_nanoseconds(start_time) if parent_otel_span is not None: with otel_trace_api.use_span(parent_otel_span, end_on_exit=False): otel_span = langfuse._otel_tracer.start_span(name=name, start_time=start_ns) @@ -586,21 +1067,20 @@ def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, **obs_kwargs, ) - +# ---- Trace naming and tags ---- def collect_skill_tags(turn: Turn) -> List[str]: """Return 'skill:' tags for every Skill tool invocation in the turn.""" names: List[str] = [] - for am in turn.assistant_msgs: - for tu in get_tool_use_blocks(get_content_from_row(am)): - if tu.get("name") != "Skill": + for assistant_message in turn.assistant_msgs: + for tool_use in get_tool_use_blocks(get_content_from_row(assistant_message)): + if tool_use.get("name") != "Skill": continue - tu_input = tu.get("input") - skill = tu_input.get("skill") if isinstance(tu_input, dict) else None + tool_input = tool_use.get("input") + skill = tool_input.get("skill") if isinstance(tool_input, dict) else None if isinstance(skill, str) and skill and f"skill:{skill}" not in names: names.append(f"skill:{skill}") return names - def short_session_label(session_id: str, max_len: int = 12) -> str: """Return a compact session label for trace names.""" sid = session_id.strip() @@ -611,50 +1091,617 @@ def short_session_label(session_id: str, max_len: int = 12) -> str: return parts[0] return sid if len(sid) <= max_len else sid[:max_len].rstrip("-") - def trace_display_name(session_id: str, turn_num: int) -> str: return f"Claude Code - Turn {turn_num} ({short_session_label(session_id)})" +def get_trace_tags(turn: Turn) -> List[str]: + tags = ["claude-code"] + if SKILL_TAGS: + tags += collect_skill_tags(turn) + return tags + +# ---- Generation payloads ---- +def build_generation_input( + assistant_index: int, + user_text: str, + previous_tool_results: List[Dict[str, Any]], + ready_async_tool_results: List[Dict[str, Any]], +) -> Any: + if assistant_index == 0: + return {"role": "user", "content": user_text} + # Both feed the next generation's context: results from the previous tool + # batch AND async agent results that became ready since. + tool_results = list(previous_tool_results) + tool_results += [result["tool_result"] for result in ready_async_tool_results] + if tool_results: + return {"role": "tool", "tool_results": tool_results} + return None -def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, transcript_path: Path, - user_id: Optional[str] = None) -> None: - user_text_raw = extract_text(get_content_from_row(turn.user_msg)) - user_text, user_text_meta = truncate_text(user_text_raw) +def build_generation_output(assistant_text: str, tool_uses: List[Dict[str, Any]]) -> Dict[str, Any]: + output: Dict[str, Any] = {"role": "assistant"} + if assistant_text: + output["content"] = assistant_text + if tool_uses: + output["tool_calls"] = [ + { + "id": tool_use.get("id"), + "name": tool_use.get("name"), + } + for tool_use in tool_uses + ] + return output + +# ---- Tool observations ---- +@dataclass +class ToolResultForObservation: + output: Any = None + output_meta: Optional[Dict[str, Any]] = None + result_timestamp: Optional[datetime] = None + final_output: Any = None + final_result_timestamp: Optional[datetime] = None - last_assistant = turn.assistant_msgs[-1] - final_assistant_text, _ = truncate_text(extract_text(get_content_from_row(last_assistant))) +@dataclass +class EmittedSingleToolObservation: + handoff_timestamp: Optional[datetime] + tool_result: Dict[str, Any] + latest_end_timestamp: Optional[datetime] - user_ts = parse_timestamp(turn.user_msg) - last_assistant_ts = parse_timestamp(last_assistant) - # Pick a turn end_time: latest among final assistant message or any tool result - candidate_end_ts = [t for t in [last_assistant_ts] if t is not None] - for tr in turn.tool_results_by_id.values(): - t = parse_timestamp(tr) - if t is not None: - candidate_end_ts.append(t) - turn_end_ts = max(candidate_end_ts) if candidate_end_ts else None +@dataclass +class EmittedToolObservationBatch: + result_timestamps: List[datetime] + tool_results: List[Dict[str, Any]] + latest_end_timestamp: Optional[datetime] + +def get_tool_input_for_observation(tool_use: Dict[str, Any]) -> Tuple[Any, Optional[Dict[str, Any]]]: + tool_input_raw = ( + tool_use.get("input") + if isinstance(tool_use.get("input"), (dict, list, str, int, float, bool)) + else {} + ) + if isinstance(tool_input_raw, str): + return truncate_text(tool_input_raw) + return tool_input_raw, None + +def get_tool_result_for_observation(tool_result_entry: Any) -> ToolResultForObservation: + if not isinstance(tool_result_entry, dict): + return ToolResultForObservation() + + output_raw = tool_result_entry.get("content") + output_str = output_raw if isinstance(output_raw, str) else json.dumps(output_raw, ensure_ascii=False) + output, output_meta = truncate_text(output_str) + result_timestamp = parse_timestamp(tool_result_entry.get("timestamp")) + + final_output_raw = tool_result_entry.get("final_content") + if final_output_raw is None: + return ToolResultForObservation( + output=output, + output_meta=output_meta, + result_timestamp=result_timestamp, + ) + + final_output_str = ( + final_output_raw + if isinstance(final_output_raw, str) + else json.dumps(final_output_raw, ensure_ascii=False) + ) + final_output, _ = truncate_text(final_output_str) + final_result_timestamp = parse_timestamp(tool_result_entry.get("final_timestamp")) + return ToolResultForObservation( + output=output, + output_meta=output_meta, + result_timestamp=result_timestamp, + final_output=final_output, + final_result_timestamp=final_result_timestamp, + ) + +def get_short_transcript_path_for_metadata(path: Any) -> Optional[str]: + if isinstance(path, Path): + return path.name + if isinstance(path, str) and path: + return Path(path).name + return None + +def build_tool_metadata( + tool_name: str, + tool_use_id: str, + tool_input_meta: Optional[Dict[str, Any]], + tool_result: ToolResultForObservation, + subagent: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + tool_metadata: Dict[str, Any] = { + "tool_name": tool_name, + "tool_id": tool_use_id, + "input_meta": tool_input_meta, + "output_meta": tool_result.output_meta, + } + if subagent: + tool_metadata.update({ + "subagent_type": subagent.get("agent_type"), + "subagent_description": subagent.get("description"), + "subagent_transcript_path": get_short_transcript_path_for_metadata(subagent.get("path")), + }) + return tool_metadata + +def emit_single_tool_observation( + langfuse: Langfuse, + parent_otel_span: Any, + turn: Turn, + assistant_timestamp: Optional[datetime], + tool_use: Dict[str, Any], + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]], + pending_subagents: List[Dict[str, Any]], + pending_async_tool_results: List[Dict[str, Any]], +) -> EmittedSingleToolObservation: + tool_use_id = str(tool_use.get("id") or "") + tool_name = tool_use.get("name") or "unknown" + tool_input, tool_input_meta = get_tool_input_for_observation(tool_use) + + tool_result_entry = turn.tool_results_by_id.get(tool_use_id) if tool_use_id else None + tool_result = get_tool_result_for_observation(tool_result_entry) + + tool_output: Any = tool_result.output + if CAPTURE_SKILL_CONTENT: + injected = turn.injected_by_tool_id.get(tool_use_id) if tool_use_id else None + if injected: + injected_trunc, _ = truncate_text(injected) + tool_output = {"result": tool_result.output, "injected_instructions": injected_trunc} + + subagent = ( + subagent_transcripts_by_tool_use_id.get(tool_use_id) + if subagent_transcripts_by_tool_use_id and tool_use_id + else None + ) + tool_metadata = build_tool_metadata(tool_name, tool_use_id, tool_input_meta, tool_result, subagent) + + tool_use_timestamp = parse_timestamp(turn.tool_use_timestamps_by_id.get(tool_use_id)) or assistant_timestamp + tool_span = _start_backdated( + langfuse, + name=f"Tool: {tool_name}", + as_type="tool", + start_time=tool_use_timestamp, + parent_otel_span=parent_otel_span, + input=tool_input, + metadata=tool_metadata, + ) + tool_span.update(output=tool_output) + + subagent_end_timestamp = None + if subagent: + if tool_result.final_result_timestamp is not None: + pending_subagents.append({ + "tool_use_id": tool_use_id, + "subagent": subagent, + "start_timestamp": tool_use_timestamp, + "ready_timestamp": tool_result.final_result_timestamp, + }) + else: + subagent_end_timestamp = emit_subagent_observations( + langfuse, + parent_otel_span, + subagent, + tool_use_timestamp, + ) + + tool_end_timestamp = _get_latest_timestamp(tool_result.result_timestamp, tool_use_timestamp) + handoff_timestamp = ( + tool_result.result_timestamp + or tool_result.final_result_timestamp + or subagent_end_timestamp + or assistant_timestamp + ) + tool_span.end(end_time=to_otel_nanoseconds(tool_end_timestamp)) + + if tool_result.final_result_timestamp is not None and tool_result.final_output is not None: + pending_async_tool_results.append({ + "timestamp": tool_result.final_result_timestamp, + "tool_result": { + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": tool_result.final_output, + }, + }) + + return EmittedSingleToolObservation( + handoff_timestamp=handoff_timestamp, + tool_result={ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": tool_result.output, + }, + latest_end_timestamp=_get_latest_timestamp(tool_end_timestamp, subagent_end_timestamp), + ) + +def emit_tool_observation_batch( + langfuse: Langfuse, + parent_otel_span: Any, + turn: Turn, + assistant_message: Dict[str, Any], + tool_uses: List[Dict[str, Any]], + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]], + pending_subagents: List[Dict[str, Any]], + pending_async_tool_results: List[Dict[str, Any]], +) -> EmittedToolObservationBatch: + assistant_timestamp = parse_timestamp(assistant_message) + tool_result_timestamps: List[datetime] = [] + emitted_tool_results: List[Dict[str, Any]] = [] + latest_tool_end_timestamp: Optional[datetime] = None + + for tool_use in tool_uses: + emitted_tool = emit_single_tool_observation( + langfuse, + parent_otel_span, + turn, + assistant_timestamp, + tool_use, + subagent_transcripts_by_tool_use_id, + pending_subagents, + pending_async_tool_results, + ) + if emitted_tool.handoff_timestamp is not None: + tool_result_timestamps.append(emitted_tool.handoff_timestamp) + emitted_tool_results.append(emitted_tool.tool_result) + latest_tool_end_timestamp = _get_latest_timestamp( + latest_tool_end_timestamp, + emitted_tool.latest_end_timestamp, + ) + + return EmittedToolObservationBatch( + result_timestamps=tool_result_timestamps, + tool_results=emitted_tool_results, + latest_end_timestamp=latest_tool_end_timestamp, + ) + +# ---- Turn and subagent observations ---- +def get_ready_subagents( + pending_subagents: List[Dict[str, Any]], + assistant_timestamp: Optional[datetime], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + ready_subagents: List[Dict[str, Any]] = [] + still_pending_subagents: List[Dict[str, Any]] = [] + for pending_subagent in pending_subagents: + ready_timestamp = pending_subagent.get("ready_timestamp") + if isinstance(ready_timestamp, datetime) and ( + assistant_timestamp is None or ready_timestamp <= assistant_timestamp + ): + ready_subagents.append(pending_subagent) + else: + still_pending_subagents.append(pending_subagent) + return ready_subagents, still_pending_subagents + +def get_ready_async_tool_results( + pending_async_tool_results: List[Dict[str, Any]], + assistant_timestamp: Optional[datetime], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Optional[datetime]]: + ready_async_tool_results: List[Dict[str, Any]] = [] + still_pending_tool_results: List[Dict[str, Any]] = [] + for async_tool_result in pending_async_tool_results: + async_result_timestamp = async_tool_result.get("timestamp") + if isinstance(async_result_timestamp, datetime) and ( + assistant_timestamp is None or async_result_timestamp <= assistant_timestamp + ): + ready_async_tool_results.append(async_tool_result) + else: + still_pending_tool_results.append(async_tool_result) + + latest_ready_timestamp = _get_latest_timestamp(*[ + result.get("timestamp") + for result in ready_async_tool_results + if isinstance(result.get("timestamp"), datetime) + ]) + return ready_async_tool_results, still_pending_tool_results, latest_ready_timestamp + +def update_pending_subagent_display_start_after_launch_response( + pending_subagents: List[Dict[str, Any]], + tool_results_used_as_generation_input: List[Dict[str, Any]], + generation_start_timestamp: Optional[datetime], +) -> None: + if generation_start_timestamp is None: + return + + tool_use_ids = { + str(tool_result.get("tool_use_id")) + for tool_result in tool_results_used_as_generation_input + if isinstance(tool_result, dict) and tool_result.get("tool_use_id") + } + if not tool_use_ids: + return + + for pending_subagent in pending_subagents: + if pending_subagent.get("display_start_timestamp") is not None: + continue + if pending_subagent.get("tool_use_id") in tool_use_ids: + pending_subagent["display_start_timestamp"] = generation_start_timestamp + timedelta( + microseconds=1 + ) + +def build_generation_kwargs( + assistant_index: int, + assistant_message: Dict[str, Any], + user_text: str, + previous_tool_results: List[Dict[str, Any]], + ready_async_tool_results: List[Dict[str, Any]], +) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + assistant_text_raw = extract_text_from_content(get_content_from_row(assistant_message)) + assistant_text, assistant_text_meta = truncate_text(assistant_text_raw) + tool_uses = get_tool_use_blocks(get_content_from_row(assistant_message)) + + generation_kwargs: Dict[str, Any] = dict( + model=get_model(assistant_message), + input=build_generation_input( + assistant_index, + user_text, + previous_tool_results, + ready_async_tool_results, + ), + output=build_generation_output(assistant_text, tool_uses), + metadata={ + "assistant_index": assistant_index, + "assistant_text": assistant_text_meta, + "tool_count": len(tool_uses), + }, + ) + usage_details = get_usage_details_from_row(assistant_message) + if usage_details is not None: + generation_kwargs["usage_details"] = usage_details + return generation_kwargs, tool_uses + +def emit_generation_observation( + langfuse: Langfuse, + parent_otel_span: Any, + generation_prefix: str, + assistant_index: int, + start_timestamp: Optional[datetime], + generation_kwargs: Dict[str, Any], +) -> Any: + return _start_backdated( + langfuse, + name=f"{generation_prefix} {assistant_index + 1}", + as_type="generation", + start_time=start_timestamp, + parent_otel_span=parent_otel_span, + **generation_kwargs, + ) + +def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, + start_timestamp: Optional[datetime], + generation_prefix: str = "LLM Call", + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None) -> Optional[datetime]: + """Emit a turn's generations and tool observations under an existing span.""" + user_text, _ = truncate_text(extract_text_from_content(get_content_from_row(turn.user_msg))) + previous_timestamp = start_timestamp + previous_tool_results: List[Dict[str, Any]] = [] + pending_async_tool_results: List[Dict[str, Any]] = [] + pending_subagents: List[Dict[str, Any]] = [] + latest_end_timestamp = start_timestamp + + for assistant_index, assistant_message in enumerate(turn.assistant_msgs): + assistant_timestamp = parse_timestamp(assistant_message) + if assistant_index > 0 and pending_subagents: + ready_subagents, pending_subagents = get_ready_subagents( + pending_subagents, + assistant_timestamp, + ) + for ready_subagent in ready_subagents: + subagent_end_timestamp = emit_subagent_observations( + langfuse, + parent_otel_span, + ready_subagent["subagent"], + ready_subagent.get("display_start_timestamp") or ready_subagent.get("start_timestamp"), + ) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) + + ready_async_tool_results: List[Dict[str, Any]] = [] + if assistant_index > 0 and pending_async_tool_results: + ready_async_tool_results, pending_async_tool_results, ready_async_result_timestamp = ( + get_ready_async_tool_results(pending_async_tool_results, assistant_timestamp) + ) + previous_timestamp = _get_latest_timestamp(previous_timestamp, ready_async_result_timestamp) + + generation_kwargs, tool_uses = build_generation_kwargs( + assistant_index, + assistant_message, + user_text, + previous_tool_results, + ready_async_tool_results, + ) + generation_start_timestamp = previous_timestamp or assistant_timestamp + generation_span = emit_generation_observation( + langfuse, + parent_otel_span=parent_otel_span, + generation_prefix=generation_prefix, + assistant_index=assistant_index, + start_timestamp=generation_start_timestamp, + generation_kwargs=generation_kwargs, + ) + update_pending_subagent_display_start_after_launch_response( + pending_subagents, + previous_tool_results, + generation_start_timestamp, + ) + + emitted_tools = emit_tool_observation_batch( + langfuse, + parent_otel_span, + turn, + assistant_message, + tool_uses, + subagent_transcripts_by_tool_use_id, + pending_subagents, + pending_async_tool_results, + ) + latest_end_timestamp = _get_latest_timestamp( + latest_end_timestamp, + emitted_tools.latest_end_timestamp, + ) + + generation_end_timestamp = ( + max(emitted_tools.result_timestamps) + if emitted_tools.result_timestamps + else assistant_timestamp + ) + generation_span.end( + end_time=to_otel_nanoseconds( + generation_end_timestamp or assistant_timestamp or previous_timestamp + ) + ) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, generation_end_timestamp) + + previous_tool_results = emitted_tools.tool_results + if emitted_tools.result_timestamps: + previous_timestamp = max(emitted_tools.result_timestamps) + elif assistant_timestamp is not None: + previous_timestamp = assistant_timestamp + + for pending_subagent in pending_subagents: + subagent_end_timestamp = emit_subagent_observations( + langfuse, + parent_otel_span, + pending_subagent["subagent"], + pending_subagent.get("display_start_timestamp") or pending_subagent.get("start_timestamp"), + ) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) + + return latest_end_timestamp + +def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, + subagent: Dict[str, Any], + start_timestamp: Optional[datetime]) -> Optional[datetime]: + path = subagent.get("path") + if not isinstance(path, Path): + return start_timestamp + rows = read_subagent_jsonl(path) + if rows is None: + return start_timestamp + + turns = build_turns(rows) + if not turns: + return start_timestamp + + first_turn = turns[0] + subagent_start_timestamp = start_timestamp or parse_timestamp(first_turn.user_msg) + subagent_input_text, subagent_input_meta = truncate_text(extract_text_from_content(get_content_from_row(first_turn.user_msg))) + + last_turn = turns[-1] + last_assistant = last_turn.assistant_msgs[-1] + subagent_output_text, _ = truncate_text(extract_text_from_content(get_content_from_row(last_assistant))) + + description = subagent.get("description") + subagent_name = f"Subagent: {description}" if isinstance(description, str) and description else "Subagent" + subagent_span = _start_backdated( + langfuse, + name=subagent_name, + as_type="span", + start_time=subagent_start_timestamp, + parent_otel_span=parent_otel_span, + input={"role": "user", "content": subagent_input_text}, + metadata={ + "agent_type": subagent.get("agent_type"), + "description": description, + "transcript_path": get_short_transcript_path_for_metadata(path), + "user_text": subagent_input_meta, + }, + ) + + latest_end_timestamp = subagent_start_timestamp + previous_start_timestamp = subagent_start_timestamp + for turn in turns: + latest_turn_timestamp = emit_turn_observations( + langfuse, + subagent_span._otel_span, + turn, + previous_start_timestamp, + generation_prefix="Subagent LLM Call", + subagent_transcripts_by_tool_use_id=None, + ) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, latest_turn_timestamp) + if latest_turn_timestamp is not None: + previous_start_timestamp = latest_turn_timestamp + + subagent_span.update(output={"role": "assistant", "content": subagent_output_text}) + subagent_span.end( + end_time=to_otel_nanoseconds( + _get_latest_timestamp(latest_end_timestamp, subagent_start_timestamp) + ) + ) + + return latest_end_timestamp + +def read_subagent_jsonl(path: Path) -> Optional[List[Dict[str, Any]]]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except Exception as e: + info(f"subagent transcript read failed ({path}): {type(e).__name__}: {e}") + return None + rows: List[Dict[str, Any]] = [] + for line_number, line in enumerate(lines, start=1): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except Exception as e: + info(f"subagent transcript line skipped ({path}:{line_number}): {type(e).__name__}: {e}") + continue + if not isinstance(row, dict): + info(f"subagent transcript line skipped ({path}:{line_number}): expected JSON object") + continue + rows.append(row) + return rows + +def get_turn_end_timestamp(turn: Turn) -> Optional[datetime]: + last_assistant_timestamp = parse_timestamp(turn.assistant_msgs[-1]) if turn.assistant_msgs else None + candidate_end_timestamps = [ + timestamp + for timestamp in [last_assistant_timestamp] + if timestamp is not None + ] + for tool_result_entry in turn.tool_results_by_id.values(): + timestamp = parse_timestamp(tool_result_entry) + if timestamp is not None: + candidate_end_timestamps.append(timestamp) + return max(candidate_end_timestamps) if candidate_end_timestamps else None + +def build_trace_metadata( + session_id: str, + turn_num: int, + turn: Turn, + transcript_path: Path, + user_text_meta: Dict[str, Any], +) -> Dict[str, Any]: trace_metadata: Dict[str, Any] = { "source": "claude-code", "session_id": session_id, "turn_number": turn_num, - "transcript_path": str(transcript_path), + "transcript_path": get_short_transcript_path_for_metadata(transcript_path), "user_text": user_text_meta, "assistant_message_count": len(turn.assistant_msgs), } - # Transcript rows carry the project dir and git branch — surface them so - # traces from different projects/worktrees are distinguishable in Langfuse. + # Transcript rows carry the project dir and git branch so traces from + # different projects/worktrees are distinguishable in Langfuse. for src_key, dst_key in (("cwd", "cwd"), ("gitBranch", "git_branch")): - v = turn.user_msg.get(src_key) - if isinstance(v, str) and v: - trace_metadata[dst_key] = v + value = turn.user_msg.get(src_key) + if isinstance(value, str) and value: + trace_metadata[dst_key] = value + return trace_metadata - tags = ["claude-code"] - if SKILL_TAGS: - tags += collect_skill_tags(turn) +def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, transcript_path: Path, + user_id: Optional[str] = None, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None) -> None: + user_text_raw = extract_text_from_content(get_content_from_row(turn.user_msg)) + user_text, user_text_meta = truncate_text(user_text_raw) + + last_assistant = turn.assistant_msgs[-1] + final_assistant_text, _ = truncate_text(extract_text_from_content(get_content_from_row(last_assistant))) + + user_ts = parse_timestamp(turn.user_msg) + last_assistant_ts = parse_timestamp(last_assistant) + turn_end_ts = get_turn_end_timestamp(turn) + trace_metadata = build_trace_metadata(session_id, turn_num, turn, transcript_path, user_text_meta) + tags = get_trace_tags(turn) trace_name = trace_display_name(session_id, turn_num) - root_observation_name = f"Turn {turn_num}" + root_observation_name = "Conversational Turn" with propagate_attributes( session_id=session_id, @@ -670,213 +1717,144 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr input={"role": "user", "content": user_text}, metadata=trace_metadata, ) - parent_otel_span = trace_span._otel_span - - # Iterate each assistant message: emit generation, then its tool_use children. - # prev_ts = the moment the next generation could have started (= when the previous - # batch of tool results all returned, or the original user message timestamp). - prev_ts = user_ts - prev_tool_results: List[Dict[str, Any]] = [] # populated after each batch, surfaced as next gen's input - - for idx, am in enumerate(turn.assistant_msgs): - am_ts = parse_timestamp(am) - am_text_raw = extract_text(get_content_from_row(am)) - am_text, am_text_meta = truncate_text(am_text_raw) - model = get_model(am) - tool_uses = get_tool_use_blocks(get_content_from_row(am)) - - # Build generation input: user message for first generation, otherwise tool results from - # the prior batch (best partial reconstruction of the prompt context). - if idx == 0: - gen_input: Any = {"role": "user", "content": user_text} - elif prev_tool_results: - gen_input = {"role": "tool", "tool_results": prev_tool_results} - else: - gen_input = None - - # Build generation output: include both the text response and any tool calls the LLM - # decided to make. Most assistant messages in tool-using turns are tool-call-only, so - # without tool_calls in the output, the observation looks empty. - gen_tool_calls = [] - for tu in tool_uses: - tu_input = tu.get("input") - if isinstance(tu_input, str): - tu_input_serialized, _ = truncate_text(tu_input) - else: - tu_input_serialized = tu_input - gen_tool_calls.append({ - "id": tu.get("id"), - "name": tu.get("name"), - "input": tu_input_serialized, - }) - - gen_output: Dict[str, Any] = {"role": "assistant"} - if am_text: - gen_output["content"] = am_text - if gen_tool_calls: - gen_output["tool_calls"] = gen_tool_calls - - gen_kwargs: Dict[str, Any] = dict( - model=model, - input=gen_input, - output=gen_output, - metadata={ - "assistant_index": idx, - "assistant_text": am_text_meta, - "tool_count": len(tool_uses), - }, - ) - usage_details = get_usage_details_from_row(am) - if usage_details is not None: - gen_kwargs["usage_details"] = usage_details - - gen_span = _start_backdated( + obs_end_ts = emit_turn_observations( + langfuse, + trace_span._otel_span, + turn, + user_ts, + subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id, + ) + trace_span.update(output={"role": "assistant", "content": final_assistant_text}) + trace_span.end(end_time=to_otel_nanoseconds(_get_latest_timestamp(turn_end_ts, last_assistant_ts, obs_end_ts, user_ts))) + + +# ---- New turn emission orchestration ---- +def emit_ready_turns( + langfuse: Langfuse, + session_id: str, + transcript_path: Path, + turns_to_emit: List[Turn], + session_state: SessionState, + *, + user_id: Optional[str], + subagent_transcripts_by_tool_use_id: Dict[str, Dict[str, Any]], +) -> int: + emitted = 0 + for turn in turns_to_emit: + emitted += 1 + turn_num = session_state.turn_count + emitted + try: + emit_turn( langfuse, - name=f"Claude Generation {idx + 1}", - as_type="generation", - start_time=prev_ts or am_ts, - parent_otel_span=parent_otel_span, - **gen_kwargs, + session_id, + turn_num, + turn, + transcript_path, + user_id=user_id, + subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id, ) + except Exception as e: + # Log at INFO so SDK incompatibilities (and other emit failures) + # are visible without needing CC_LANGFUSE_DEBUG=true. + info(f"emit_turn failed: {type(e).__name__}: {e}") + return emitted + +def emit_new_turns_from_transcript( + langfuse: Langfuse, + config: LangfuseConfig, + session_id: str, + transcript_path: Path, + *, + flush_deferred_agent_turns: bool = False, +) -> int: + with FileLock(LOCK_FILE): + state = load_hook_state() + key = get_session_state_key(session_id, str(transcript_path)) + session_state = get_session_state(state, key) + + subagent_transcripts_by_tool_use_id = get_subagent_transcripts_by_tool_use_id(transcript_path) + if subagent_transcripts_by_tool_use_id: + debug(f"Discovered {len(subagent_transcripts_by_tool_use_id)} subagent transcript(s)") + + turns, session_state = get_new_turns_from_transcript( + transcript_path, + session_state, + subagent_transcripts_by_tool_use_id, + flush_deferred_agent_turns=flush_deferred_agent_turns, + ) + if not turns: + save_session_state(state, key, session_state) + return 0 + + turns_to_emit = get_turns_to_emit( + turns, + session_state, + flush_deferred_agent_turns=flush_deferred_agent_turns, + ) + emitted = emit_ready_turns( + langfuse, + session_id, + transcript_path, + turns_to_emit, + session_state, + user_id=config.user_id, + subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id, + ) - # Tool observations: nested under this generation. Each starts when the assistant - # emitted the tool_use (am_ts) and ends when its tool_result row arrived. - batch_result_ts: List[datetime] = [] - batch_tool_results: List[Dict[str, Any]] = [] - for tu in tool_uses: - tid = str(tu.get("id") or "") - tname = tu.get("name") or "unknown" - tinput_raw = tu.get("input") if isinstance(tu.get("input"), (dict, list, str, int, float, bool)) else {} - if isinstance(tinput_raw, str): - tinput, tinput_meta = truncate_text(tinput_raw) - else: - tinput, tinput_meta = tinput_raw, None - - tr_entry = turn.tool_results_by_id.get(tid) if tid else None - if tr_entry: - out_raw = tr_entry.get("content") - out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False) - out_trunc, out_meta = truncate_text(out_str) - tr_ts = parse_timestamp(tr_entry.get("timestamp")) - else: - out_trunc, out_meta, tr_ts = None, None, None - if tr_ts is not None: - batch_result_ts.append(tr_ts) - - # Skill invocations inject their instructions as a separate transcript - # row; optionally surface them on the tool span they belong to. - tool_output: Any = out_trunc - if CAPTURE_SKILL_CONTENT: - injected = turn.injected_by_tool_id.get(tid) if tid else None - if injected: - injected_trunc, _ = truncate_text(injected) - tool_output = {"result": out_trunc, "injected_instructions": injected_trunc} - - tool_span = _start_backdated( - langfuse, - name=f"Tool: {tname}", - as_type="tool", - start_time=am_ts, - parent_otel_span=gen_span._otel_span, - input=tinput, - metadata={ - "tool_name": tname, - "tool_id": tid, - "input_meta": tinput_meta, - "output_meta": out_meta, - }, - ) - tool_span.update(output=tool_output) - tool_span.end(end_time=_to_ns(tr_ts or am_ts)) + session_state.turn_count += emitted + save_session_state(state, key, session_state) + return emitted - batch_tool_results.append({ - "tool_use_id": tid, - "tool_name": tname, - "output": out_trunc, - }) - # End the generation AFTER its tools so the timeline cleanly contains them. - # If there were tool calls, gen ends with the last result; otherwise at am_ts. - gen_end_ts = max(batch_result_ts) if batch_result_ts else am_ts - gen_span.end(end_time=_to_ns(gen_end_ts or am_ts or prev_ts)) +def flush_and_shutdown_langfuse_client(langfuse: Optional[Langfuse]) -> None: + if langfuse is None: + return - # Carry this batch's results into the next generation's input. - prev_tool_results = batch_tool_results + # Cap flush+shutdown at 5s so a slow/unreachable Langfuse can't stall Claude Code. + try: + def _flush_and_shutdown(): + try: + langfuse.flush() + except Exception: + pass + langfuse.shutdown() - # Advance prev_ts: next generation can only start after this batch's tool results returned. - if batch_result_ts: - prev_ts = max(batch_result_ts) - elif am_ts is not None: - prev_ts = am_ts + t = threading.Thread(target=_flush_and_shutdown, daemon=True) + t.start() + t.join(5.0) + except Exception: + pass - trace_span.update(output={"role": "assistant", "content": final_assistant_text}) - trace_span.end(end_time=_to_ns(turn_end_ts or last_assistant_ts or user_ts)) # ----------------- Main ----------------- def main() -> int: start = time.time() debug("Hook started") - public_key = _opt("LANGFUSE_PUBLIC_KEY") or _opt("CC_LANGFUSE_PUBLIC_KEY") - secret_key = _opt("LANGFUSE_SECRET_KEY") or _opt("CC_LANGFUSE_SECRET_KEY") - host = _opt("LANGFUSE_BASE_URL") or _opt("CC_LANGFUSE_BASE_URL") or "https://us.cloud.langfuse.com" - user_id = _opt("LANGFUSE_USER_ID") or _opt("CC_LANGFUSE_USER_ID") or None - - if not public_key or not secret_key: + config = get_langfuse_config() + if config is None: return 0 payload = read_hook_payload() - session_id, transcript_path = extract_session_id_and_transcript_path(payload) - - if not session_id or not transcript_path: - # No structured payload; fail open (do not guess) - debug("Missing session_id or transcript_path from hook payload; exiting.") + hook_context = get_session_id_and_transcript_path(payload) + if hook_context is None: return 0 - if not transcript_path.exists(): - debug(f"Transcript path does not exist: {transcript_path}") - return 0 + session_id, transcript_path = hook_context + flush_deferred_agent_turns = is_session_end_hook_payload(payload) - langfuse = None - try: - langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host) - except Exception: + langfuse = create_langfuse_client(config) + if langfuse is None: return 0 try: - with FileLock(LOCK_FILE): - state = load_state() - key = state_key(session_id, str(transcript_path)) - ss = load_session_state(state, key) - - msgs, ss = read_new_jsonl(transcript_path, ss) - if not msgs: - write_session_state(state, key, ss) - save_state(state) - return 0 - - turns = build_turns(msgs) - if not turns: - write_session_state(state, key, ss) - save_state(state) - return 0 - - # emit turns - emitted = 0 - for t in turns: - emitted += 1 - turn_num = ss.turn_count + emitted - try: - emit_turn(langfuse, session_id, turn_num, t, transcript_path, user_id=user_id) - except Exception as e: - # Log at INFO so SDK incompatibilities (and other emit failures) - # are visible without needing CC_LANGFUSE_DEBUG=true. - info(f"emit_turn failed: {type(e).__name__}: {e}") - # continue emitting other turns - - ss.turn_count += emitted - write_session_state(state, key, ss) - save_state(state) + emitted = emit_new_turns_from_transcript( + langfuse, + config, + session_id, + transcript_path, + flush_deferred_agent_turns=flush_deferred_agent_turns, + ) dur = time.time() - start info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})") @@ -891,20 +1869,7 @@ def main() -> int: return 0 finally: - # Cap flush+shutdown at 5s so a slow/unreachable Langfuse can't stall Claude Code. - if langfuse is not None: - try: - def _flush_and_shutdown(): - try: - langfuse.flush() - except Exception: - pass - langfuse.shutdown() - t = threading.Thread(target=_flush_and_shutdown, daemon=True) - t.start() - t.join(5.0) - except Exception: - pass + flush_and_shutdown_langfuse_client(langfuse) if __name__ == "__main__": sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9e316c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "claude-observability-plugin" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.0,<9", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d1acc61 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import json +import sys +import types +from pathlib import Path +from typing import Any, Iterator + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "transcripts" + + +def _install_langfuse_stubs() -> None: + langfuse_module = types.ModuleType("langfuse") + + class Langfuse: + pass + + @contextlib.contextmanager + def propagate_attributes(**_: Any) -> Iterator[None]: + yield + + langfuse_module.Langfuse = Langfuse + langfuse_module.propagate_attributes = propagate_attributes + sys.modules["langfuse"] = langfuse_module + + opentelemetry_module = types.ModuleType("opentelemetry") + trace_module = types.ModuleType("opentelemetry.trace") + + @contextlib.contextmanager + def use_span(*_: Any, **__: Any) -> Iterator[None]: + yield + + trace_module.use_span = use_span + opentelemetry_module.trace = trace_module + sys.modules["opentelemetry"] = opentelemetry_module + sys.modules["opentelemetry.trace"] = trace_module + + +@pytest.fixture(scope="session") +def hook_module() -> Any: + _install_langfuse_stubs() + module_path = REPO_ROOT / "hooks" / "langfuse_hook.py" + spec = importlib.util.spec_from_file_location("langfuse_hook_under_test", module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def fixture_transcript_path() -> Any: + def _path(name: str) -> Path: + return FIXTURE_ROOT / name / "transcript.jsonl" + + return _path + + +def read_jsonl(path: Path) -> list[dict[str, Any]]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +@pytest.fixture +def read_fixture_jsonl() -> Any: + return read_jsonl + + +class FakeOtelSpan: + def __init__(self, name: str, start_time: int | None) -> None: + self.name = name + self.start_time = start_time + + +class FakeTracer: + def start_span(self, *, name: str, start_time: int | None = None) -> FakeOtelSpan: + return FakeOtelSpan(name, start_time) + + +class FakeObservation: + def __init__(self, otel_span: FakeOtelSpan, as_type: str, kwargs: dict[str, Any]) -> None: + self._otel_span = otel_span + self.name = otel_span.name + self.as_type = as_type + self.kwargs = kwargs + self.output: Any = None + self.end_time: int | None = None + + def update(self, **kwargs: Any) -> None: + if "output" in kwargs: + self.output = kwargs["output"] + self.kwargs.update(kwargs) + + def end(self, *, end_time: int | None = None) -> None: + self.end_time = end_time + + +class FakeLangfuse: + def __init__(self) -> None: + self._otel_tracer = FakeTracer() + self.observations: list[FakeObservation] = [] + + def _create_observation_from_otel_span( + self, + *, + otel_span: FakeOtelSpan, + as_type: str, + **kwargs: Any, + ) -> FakeObservation: + observation = FakeObservation(otel_span, as_type, kwargs) + self.observations.append(observation) + return observation + + +@pytest.fixture +def fake_langfuse() -> FakeLangfuse: + return FakeLangfuse() + + +@pytest.fixture +def isolated_hook_state(tmp_path: Path, hook_module: Any, monkeypatch: pytest.MonkeyPatch) -> Path: + state_dir = tmp_path / "claude-state" + monkeypatch.setattr(hook_module, "STATE_DIR", state_dir) + monkeypatch.setattr(hook_module, "STATE_FILE", state_dir / "langfuse_state.json") + monkeypatch.setattr(hook_module, "LOCK_FILE", state_dir / "langfuse_state.lock") + monkeypatch.setattr(hook_module, "LOG_FILE", state_dir / "langfuse_hook.log") + return state_dir diff --git a/tests/fixtures/transcripts/async_agent_completed/transcript.jsonl b/tests/fixtures/transcripts/async_agent_completed/transcript.jsonl new file mode 100644 index 0000000..2e2c4cb --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_completed/transcript.jsonl @@ -0,0 +1,15 @@ +{"type":"mode","sessionId":"session-agent-complete","mode":"default"} +{"type":"permission-mode","sessionId":"session-agent-complete","permissionMode":"default"} +{"type":"file-history-snapshot","messageId":"snapshot-agent-complete","snapshot":{},"isSnapshotUpdate":false} +{"type":"user","timestamp":"2026-01-01T00:02:00.000Z","sessionId":"session-agent-complete","uuid":"user-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"default","promptId":"prompt-agent-complete","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Ask a subagent for a summary."}} +{"type":"attachment","timestamp":"2026-01-01T00:02:00.000Z","sessionId":"session-agent-complete","uuid":"attachment-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"user-agent-complete-1","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"attachment":{"type":"text","name":"README.md"}} +{"type":"ai-title","sessionId":"session-agent-complete","aiTitle":"Subagent summary"} +{"type":"assistant","timestamp":"2026-01-01T00:02:01.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-1","requestId":"req-agent-complete-1","message":{"id":"msg-agent-complete-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_agent_complete","name":"Agent","input":{"description":"Summarize docs","prompt":"Summarize the docs","subagent_type":"general-purpose"}}]}} +{"type":"user","timestamp":"2026-01-01T00:02:02.000Z","sessionId":"session-agent-complete","uuid":"tool-result-agent-complete","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_agent_complete","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: agent-complete (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-agent-complete.txt\nYou will be notified automatically when the agent completes."}]}]}} +{"type":"assistant","timestamp":"2026-01-01T00:02:03.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-2","requestId":"req-agent-complete-2","message":{"id":"msg-agent-complete-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"I launched the subagent."}]}} +{"type":"system","timestamp":"2026-01-01T00:02:03.500Z","sessionId":"session-agent-complete","uuid":"system-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"assistant-agent-complete-2","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"level":"info","subtype":"hook","hookCount":1,"hookErrors":[],"hookInfos":[],"hookAdditionalContext":null,"hasOutput":false,"preventedContinuation":false,"stopReason":"stop","toolUseID":null} +{"type":"queue-operation","timestamp":"2026-01-01T00:02:04.000Z","sessionId":"session-agent-complete","operation":"append","content":"agent-completetoolu_agent_completeSubagent summary is ready."} +{"type":"queue-operation","timestamp":"2026-01-01T00:02:04.500Z","sessionId":"session-agent-complete","operation":"flush"} +{"type":"user","timestamp":"2026-01-01T00:02:05.000Z","sessionId":"session-agent-complete","uuid":"task-notification-agent-complete","origin":{"kind":"task-notification"},"message":{"role":"user","content":"agent-completetoolu_agent_completeSubagent summary is ready."}} +{"type":"assistant","timestamp":"2026-01-01T00:02:06.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-3","requestId":"req-agent-complete-3","message":{"id":"msg-agent-complete-3","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Here is the summary."}]}} +{"type":"last-prompt","sessionId":"session-agent-complete","leafUuid":"assistant-agent-complete-3","lastPrompt":"Ask a subagent for a summary."} diff --git a/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.jsonl b/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.jsonl new file mode 100644 index 0000000..c73fbac --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.jsonl @@ -0,0 +1,4 @@ +{"type":"user","timestamp":"2026-01-01T00:02:01.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-user-1","message":{"role":"user","content":"Summarize the docs"}} +{"type":"assistant","timestamp":"2026-01-01T00:02:03.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-assistant-1","requestId":"req-subagent-complete-1","message":{"id":"msg-subagent-complete-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_subagent_search","name":"ToolSearch","input":{"query":"docs summary","max_results":3}}]}} +{"type":"user","timestamp":"2026-01-01T00:02:04.000Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-tool-result-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_subagent_search","content":"Found docs."}]}} +{"type":"assistant","timestamp":"2026-01-01T00:02:04.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-assistant-2","requestId":"req-subagent-complete-2","message":{"id":"msg-subagent-complete-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Subagent summary is ready."}]}} diff --git a/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.meta.json b/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.meta.json new file mode 100644 index 0000000..a0ccc23 --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.meta.json @@ -0,0 +1 @@ +{"agentType":"general-purpose","description":"Summarize docs","toolUseId":"toolu_agent_complete","spawnDepth":1} diff --git a/tests/fixtures/transcripts/async_agent_deferred/transcript.jsonl b/tests/fixtures/transcripts/async_agent_deferred/transcript.jsonl new file mode 100644 index 0000000..97f4b5c --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_deferred/transcript.jsonl @@ -0,0 +1,7 @@ +{"type":"mode","sessionId":"session-agent-deferred","mode":"default"} +{"type":"permission-mode","sessionId":"session-agent-deferred","permissionMode":"acceptEdits"} +{"type":"user","timestamp":"2026-01-01T00:03:00.000Z","sessionId":"session-agent-deferred","uuid":"user-agent-deferred-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"acceptEdits","promptId":"prompt-agent-deferred","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Start a long-running subagent."}} +{"type":"assistant","timestamp":"2026-01-01T00:03:01.000Z","sessionId":"session-agent-deferred","uuid":"assistant-agent-deferred-1","requestId":"req-agent-deferred-1","message":{"id":"msg-agent-deferred-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_agent_deferred","name":"Agent","input":{"description":"Long research","prompt":"Research slowly","subagent_type":"general-purpose"}}]}} +{"type":"user","timestamp":"2026-01-01T00:03:02.000Z","sessionId":"session-agent-deferred","uuid":"tool-result-agent-deferred","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_agent_deferred","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: agent-deferred (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-agent-deferred.txt\nYou will be notified automatically when the agent completes."}]}]}} +{"type":"assistant","timestamp":"2026-01-01T00:03:03.000Z","sessionId":"session-agent-deferred","uuid":"assistant-agent-deferred-2","requestId":"req-agent-deferred-2","message":{"id":"msg-agent-deferred-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"The subagent is still running."}]}} +{"type":"system","timestamp":"2026-01-01T00:03:03.500Z","sessionId":"session-agent-deferred","uuid":"system-agent-deferred-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"assistant-agent-deferred-2","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"level":"info","subtype":"hook","hookCount":1,"hookErrors":[],"hookInfos":[],"hookAdditionalContext":null,"hasOutput":false,"preventedContinuation":false,"stopReason":"stop","toolUseID":null} diff --git a/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.jsonl b/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.jsonl new file mode 100644 index 0000000..dbb7474 --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.jsonl @@ -0,0 +1,2 @@ +{"type":"user","timestamp":"2026-01-01T00:03:01.500Z","sessionId":"session-agent-deferred","agentId":"agent-deferred","uuid":"subagent-deferred-user-1","message":{"role":"user","content":"Research slowly"}} +{"type":"assistant","timestamp":"2026-01-01T00:03:04.000Z","sessionId":"session-agent-deferred","agentId":"agent-deferred","uuid":"subagent-deferred-assistant-1","requestId":"req-subagent-deferred-1","message":{"id":"msg-subagent-deferred-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Partial research note."}]}} diff --git a/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.meta.json b/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.meta.json new file mode 100644 index 0000000..d1b8a53 --- /dev/null +++ b/tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.meta.json @@ -0,0 +1 @@ +{"agentType":"general-purpose","description":"Long research","toolUseId":"toolu_agent_deferred","spawnDepth":1} diff --git a/tests/fixtures/transcripts/nested_subagents/transcript.jsonl b/tests/fixtures/transcripts/nested_subagents/transcript.jsonl new file mode 100644 index 0000000..ff744a8 --- /dev/null +++ b/tests/fixtures/transcripts/nested_subagents/transcript.jsonl @@ -0,0 +1,9 @@ +{"type":"mode","sessionId":"session-nested","mode":"default"} +{"type":"permission-mode","sessionId":"session-nested","permissionMode":"default"} +{"type":"file-history-snapshot","messageId":"snapshot-nested","snapshot":{},"isSnapshotUpdate":false} +{"type":"user","timestamp":"2026-01-01T00:05:00.000Z","sessionId":"session-nested","uuid":"user-nested-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"default","promptId":"prompt-nested","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Start an outer agent."}} +{"type":"attachment","timestamp":"2026-01-01T00:05:00.000Z","sessionId":"session-nested","uuid":"attachment-nested-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"user-nested-1","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"attachment":{"type":"text","name":"plan.md"}} +{"type":"assistant","timestamp":"2026-01-01T00:05:01.000Z","sessionId":"session-nested","uuid":"assistant-nested-1","requestId":"req-nested-1","message":{"id":"msg-nested-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_outer_agent","name":"Agent","input":{"description":"Outer agent","prompt":"Delegate once","subagent_type":"general-purpose"}}]}} +{"type":"user","timestamp":"2026-01-01T00:05:02.000Z","sessionId":"session-nested","uuid":"tool-result-nested","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_outer_agent","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: outer-agent (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-outer-agent.txt\nYou will be notified automatically when the agent completes."}]}]}} +{"type":"queue-operation","timestamp":"2026-01-01T00:05:06.000Z","sessionId":"session-nested","operation":"append","content":"outer-agenttoolu_outer_agentOuter result."} +{"type":"assistant","timestamp":"2026-01-01T00:05:07.000Z","sessionId":"session-nested","uuid":"assistant-nested-2","requestId":"req-nested-2","message":{"id":"msg-nested-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Outer agent finished."}]}} diff --git a/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.jsonl b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.jsonl new file mode 100644 index 0000000..9e7ef42 --- /dev/null +++ b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.jsonl @@ -0,0 +1,2 @@ +{"type":"user","timestamp":"2026-01-01T00:05:02.750Z","sessionId":"session-nested","agentId":"inner-agent","uuid":"subagent-inner-user-1","message":{"role":"user","content":"Return inner result"}} +{"type":"assistant","timestamp":"2026-01-01T00:05:04.500Z","sessionId":"session-nested","agentId":"inner-agent","uuid":"subagent-inner-assistant-1","requestId":"req-subagent-inner-1","message":{"id":"msg-subagent-inner-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Inner result."}]}} diff --git a/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.meta.json b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.meta.json new file mode 100644 index 0000000..576424f --- /dev/null +++ b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.meta.json @@ -0,0 +1 @@ +{"agentType":"fork","description":"Inner agent","toolUseId":"toolu_inner_agent","spawnDepth":2,"isFork":true} diff --git a/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.jsonl b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.jsonl new file mode 100644 index 0000000..7c65e35 --- /dev/null +++ b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.jsonl @@ -0,0 +1,4 @@ +{"type":"user","timestamp":"2026-01-01T00:05:01.500Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-user-1","message":{"role":"user","content":"Delegate once"}} +{"type":"assistant","timestamp":"2026-01-01T00:05:02.500Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-assistant-1","requestId":"req-subagent-outer-1","message":{"id":"msg-subagent-outer-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_inner_agent","name":"Agent","input":{"description":"Inner agent","prompt":"Return inner result","subagent_type":"general-purpose"}}]}} +{"type":"user","timestamp":"2026-01-01T00:05:03.000Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-tool-result-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_inner_agent","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: inner-agent (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-inner-agent.txt\nYou will be notified automatically when the agent completes."}]}]}} +{"type":"assistant","timestamp":"2026-01-01T00:05:05.000Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-assistant-2","requestId":"req-subagent-outer-2","message":{"id":"msg-subagent-outer-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Outer result."}]}} diff --git a/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.meta.json b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.meta.json new file mode 100644 index 0000000..953ad92 --- /dev/null +++ b/tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.meta.json @@ -0,0 +1 @@ +{"agentType":"general-purpose","description":"Outer agent","toolUseId":"toolu_outer_agent","spawnDepth":1} diff --git a/tests/fixtures/transcripts/simple_turn/transcript.jsonl b/tests/fixtures/transcripts/simple_turn/transcript.jsonl new file mode 100644 index 0000000..bf22457 --- /dev/null +++ b/tests/fixtures/transcripts/simple_turn/transcript.jsonl @@ -0,0 +1,3 @@ +{"type":"user","timestamp":"2026-01-01T00:00:00.000Z","sessionId":"session-simple","uuid":"user-simple-1","cwd":"/repo","gitBranch":"feature/test","message":{"role":"user","content":"Say hello."}} +{"type":"assistant","timestamp":"2026-01-01T00:00:01.000Z","sessionId":"session-simple","uuid":"assistant-simple-1a","requestId":"req-simple-1","message":{"id":"msg-simple-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":10,"output_tokens":2}}} +{"type":"assistant","timestamp":"2026-01-01T00:00:02.000Z","sessionId":"session-simple","uuid":"assistant-simple-1b","requestId":"req-simple-1","message":{"id":"msg-simple-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"from Claude Code."}],"usage":{"input_tokens":10,"output_tokens":5}}} diff --git a/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript.jsonl b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript.jsonl new file mode 100644 index 0000000..b6540b9 --- /dev/null +++ b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript.jsonl @@ -0,0 +1,8 @@ +{"type":"mode","sessionId":"session-task-fallback","mode":"default"} +{"type":"permission-mode","sessionId":"session-task-fallback","permissionMode":"default"} +{"type":"user","timestamp":"2026-01-01T00:04:00.000Z","sessionId":"session-task-fallback","uuid":"user-task-fallback-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"default","promptId":"prompt-task-fallback","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Start an agent whose notification omits tool-use-id."}} +{"type":"assistant","timestamp":"2026-01-01T00:04:01.000Z","sessionId":"session-task-fallback","uuid":"assistant-task-fallback-1","requestId":"req-task-fallback-1","message":{"id":"msg-task-fallback-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_task_fallback","name":"Agent","input":{"description":"Fallback agent","prompt":"Return a fallback result","subagent_type":"general-purpose"}}]}} +{"type":"user","timestamp":"2026-01-01T00:04:02.000Z","sessionId":"session-task-fallback","uuid":"tool-result-task-fallback","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task_fallback","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: task-fallback (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-task-fallback.txt\nYou will be notified automatically when the agent completes."}]}]}} +{"type":"queue-operation","timestamp":"2026-01-01T00:04:02.500Z","sessionId":"session-task-fallback","operation":"flush"} +{"type":"queue-operation","timestamp":"2026-01-01T00:04:03.000Z","sessionId":"session-task-fallback","operation":"append","content":"task-fallbackFallback result through task id."} +{"type":"assistant","timestamp":"2026-01-01T00:04:04.000Z","sessionId":"session-task-fallback","uuid":"assistant-task-fallback-2","requestId":"req-task-fallback-2","message":{"id":"msg-task-fallback-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"I received the fallback result."}]}} diff --git a/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.jsonl b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.jsonl new file mode 100644 index 0000000..9a1cb2a --- /dev/null +++ b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.jsonl @@ -0,0 +1,2 @@ +{"type":"user","timestamp":"2026-01-01T00:04:01.500Z","sessionId":"session-task-fallback","agentId":"task-fallback","uuid":"subagent-task-fallback-user-1","message":{"role":"user","content":"Return a fallback result"}} +{"type":"assistant","timestamp":"2026-01-01T00:04:02.500Z","sessionId":"session-task-fallback","agentId":"task-fallback","uuid":"subagent-task-fallback-assistant-1","requestId":"req-subagent-task-fallback-1","message":{"id":"msg-subagent-task-fallback-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Fallback result through task id."}]}} diff --git a/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.meta.json b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.meta.json new file mode 100644 index 0000000..6bcc3d3 --- /dev/null +++ b/tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.meta.json @@ -0,0 +1 @@ +{"agentType":"general-purpose","description":"Fallback agent","toolUseId":"toolu_task_fallback","spawnDepth":1} diff --git a/tests/fixtures/transcripts/tool_turn/transcript.jsonl b/tests/fixtures/transcripts/tool_turn/transcript.jsonl new file mode 100644 index 0000000..f23180d --- /dev/null +++ b/tests/fixtures/transcripts/tool_turn/transcript.jsonl @@ -0,0 +1,4 @@ +{"type":"user","timestamp":"2026-01-01T00:01:00.000Z","sessionId":"session-tool","uuid":"user-tool-1","cwd":"/repo","gitBranch":"feature/test","message":{"role":"user","content":"Read the file."}} +{"type":"assistant","timestamp":"2026-01-01T00:01:01.000Z","sessionId":"session-tool","uuid":"assistant-tool-1","requestId":"req-tool-1","message":{"id":"msg-tool-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_read_1","name":"Read","input":{"file_path":"README.md"}}]}} +{"type":"user","timestamp":"2026-01-01T00:01:02.000Z","sessionId":"session-tool","uuid":"tool-result-read-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_1","content":"# Example"}]}} +{"type":"assistant","timestamp":"2026-01-01T00:01:03.000Z","sessionId":"session-tool","uuid":"assistant-tool-2","requestId":"req-tool-2","message":{"id":"msg-tool-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"The file starts with a heading."}]}} diff --git a/tests/integration/test_emit_new_turns_from_transcript.py b/tests/integration/test_emit_new_turns_from_transcript.py new file mode 100644 index 0000000..05dadff --- /dev/null +++ b/tests/integration/test_emit_new_turns_from_transcript.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json + + +def test_emit_new_turns_from_completed_async_agent_transcript( + hook_module, + fixture_transcript_path, + fake_langfuse, + isolated_hook_state, +): + transcript = fixture_transcript_path("async_agent_completed") + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + + emitted = hook_module.emit_new_turns_from_transcript( + fake_langfuse, + config, + "session-agent-complete", + transcript, + ) + + assert emitted == 1 + assert any(observation.name == "Conversational Turn" for observation in fake_langfuse.observations) + state = json.loads((isolated_hook_state / "langfuse_state.json").read_text(encoding="utf-8")) + assert next(iter(state.values()))["turn_count"] == 1 + assert next(iter(state.values()))["pending_agent_turns"] == [] + + +def test_emit_new_turns_defers_async_agent_until_session_end_flush( + hook_module, + fixture_transcript_path, + fake_langfuse, + isolated_hook_state, +): + transcript = fixture_transcript_path("async_agent_deferred") + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + + emitted = hook_module.emit_new_turns_from_transcript( + fake_langfuse, + config, + "session-agent-deferred", + transcript, + ) + + assert emitted == 0 + state = json.loads((isolated_hook_state / "langfuse_state.json").read_text(encoding="utf-8")) + pending_agent_turns = next(iter(state.values()))["pending_agent_turns"] + assert len(pending_agent_turns) == 1 + assert pending_agent_turns[0]["pending_tool_use_ids"] == ["toolu_agent_deferred"] + + emitted = hook_module.emit_new_turns_from_transcript( + fake_langfuse, + config, + "session-agent-deferred", + transcript, + flush_deferred_agent_turns=True, + ) + + assert emitted == 1 + state = json.loads((isolated_hook_state / "langfuse_state.json").read_text(encoding="utf-8")) + assert next(iter(state.values()))["turn_count"] == 1 + assert next(iter(state.values()))["pending_agent_turns"] == [] diff --git a/tests/integration/test_emit_turn_observations.py b/tests/integration/test_emit_turn_observations.py new file mode 100644 index 0000000..a033872 --- /dev/null +++ b/tests/integration/test_emit_turn_observations.py @@ -0,0 +1,60 @@ +from __future__ import annotations + + +def test_emit_turn_observations_creates_generation_tool_and_subagent_observations( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, + fake_langfuse, +): + transcript = fixture_transcript_path("async_agent_completed") + rows = read_fixture_jsonl(transcript) + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + turns = hook_module.build_turns(rows, hook_module.get_task_id_to_tool_use_id(subagents)) + parent_span = fake_langfuse._otel_tracer.start_span(name="parent", start_time=None) + + latest_end_timestamp = hook_module.emit_turn_observations( + fake_langfuse, + parent_span, + turns[0], + hook_module.parse_timestamp(turns[0].user_msg), + subagent_transcripts_by_tool_use_id=subagents, + ) + + names = [observation.name for observation in fake_langfuse.observations] + assert "LLM Call 1" in names + assert "Tool: Agent" in names + assert "Subagent: Summarize docs" in names + assert "Subagent LLM Call 1" in names + assert "Tool: ToolSearch" in names + assert latest_end_timestamp.isoformat() == "2026-01-01T00:02:06+00:00" + + agent_tool = next(observation for observation in fake_langfuse.observations if observation.name == "Tool: Agent") + assert agent_tool.kwargs["metadata"]["subagent_transcript_path"] == "agent-agent-complete.jsonl" + assert "Async agent launched successfully." in agent_tool.output + + +def test_nested_subagents_document_current_non_recursive_emission_behavior( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, + fake_langfuse, +): + transcript = fixture_transcript_path("nested_subagents") + rows = read_fixture_jsonl(transcript) + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + turns = hook_module.build_turns(rows, hook_module.get_task_id_to_tool_use_id(subagents)) + parent_span = fake_langfuse._otel_tracer.start_span(name="parent", start_time=None) + + hook_module.emit_turn_observations( + fake_langfuse, + parent_span, + turns[0], + hook_module.parse_timestamp(turns[0].user_msg), + subagent_transcripts_by_tool_use_id=subagents, + ) + + names = [observation.name for observation in fake_langfuse.observations] + assert "Subagent: Outer agent" in names + assert "Tool: Agent" in names + assert "Subagent: Inner agent" not in names diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py new file mode 100644 index 0000000..5f88a9f --- /dev/null +++ b/tests/unit/test_async_agent_deferral.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def make_user_row(uuid: str, text: str, timestamp: str) -> dict[str, Any]: + return { + "type": "user", + "timestamp": timestamp, + "uuid": uuid, + "origin": {"kind": "human"}, + "message": {"role": "user", "content": text}, + } + + +def make_assistant_row(uuid: str, message_id: str, content: list[dict[str, Any]], timestamp: str) -> dict[str, Any]: + return { + "type": "assistant", + "timestamp": timestamp, + "uuid": uuid, + "message": {"id": message_id, "role": "assistant", "model": "claude-test", "content": content}, + } + + +def make_agent_result_row( + uuid: str, + tool_use_id: str, + text: str, + timestamp: str, + tool_use_result: dict[str, Any] | None = None, +) -> dict[str, Any]: + row: dict[str, Any] = { + "type": "user", + "timestamp": timestamp, + "uuid": uuid, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": [{"type": "text", "text": text}], + } + ], + }, + } + if tool_use_result is not None: + row["toolUseResult"] = tool_use_result + return row + + +def make_async_launch_result_row(uuid: str, tool_use_id: str, timestamp: str) -> dict[str, Any]: + return make_agent_result_row( + uuid, + tool_use_id, + ( + "Async agent launched successfully.\n" + "agentId: agent-test\n" + "output_file: /tmp/agent-test.txt\n" + "You will be notified automatically when the agent completes." + ), + timestamp, + tool_use_result={"status": "async_launched", "isAsync": True, "agentId": "agent-test"}, + ) + + +def make_notification_row(uuid: str, tool_use_id: str, result: str, timestamp: str) -> dict[str, Any]: + return { + "type": "user", + "timestamp": timestamp, + "uuid": uuid, + "origin": {"kind": "task-notification"}, + "message": { + "role": "user", + "content": ( + f"{tool_use_id}" + f"{result}" + ), + }, + } + + +def append_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: + with open(path, "a", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row) + "\n") + + +def launch_turn_rows(tool_use_id: str = "toolu_bg") -> list[dict[str, Any]]: + return [ + make_user_row("user-1", "Start a background agent.", "2026-01-01T00:00:00.000Z"), + make_assistant_row( + "assistant-1", + "msg-1", + [{"type": "tool_use", "id": tool_use_id, "name": "Agent", + "input": {"description": "Research", "prompt": "Research slowly"}}], + "2026-01-01T00:00:01.000Z", + ), + make_async_launch_result_row("tool-result-1", tool_use_id, "2026-01-01T00:00:02.000Z"), + make_assistant_row( + "assistant-2", + "msg-2", + [{"type": "text", "text": "The agent is running in the background."}], + "2026-01-01T00:00:03.000Z", + ), + ] + + +def test_completed_async_agent_turn_is_ready_to_emit( + hook_module, + fixture_transcript_path, +): + transcript = fixture_transcript_path("async_agent_completed") + state = hook_module.SessionState() + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + + turns, state = hook_module.get_new_turns_from_transcript(transcript, state, subagents) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert len(turns) == 1 + assert len(turns_to_emit) == 1 + assert state.pending_agent_turns == [] + result = turns[0].tool_results_by_id["toolu_agent_complete"] + assert result["final_content"] == "Subagent summary is ready." + + +def test_uncompleted_async_agent_turn_is_deferred_until_flush( + hook_module, + fixture_transcript_path, +): + transcript = fixture_transcript_path("async_agent_deferred") + state = hook_module.SessionState() + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + + turns, state = hook_module.get_new_turns_from_transcript(transcript, state, subagents) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert turns_to_emit == [] + assert len(state.pending_agent_turns) == 1 + assert state.pending_agent_turns[0]["pending_tool_use_ids"] == ["toolu_agent_deferred"] + + flushed_turns, state = hook_module.get_new_turns_from_transcript( + transcript, + state, + subagents, + flush_deferred_agent_turns=True, + ) + flushed_to_emit = hook_module.get_turns_to_emit( + flushed_turns, + state, + flush_deferred_agent_turns=True, + ) + + assert len(flushed_to_emit) == 1 + assert state.pending_agent_turns == [] + + +def test_turn_waiting_on_multiple_agents_resolves_only_after_all_notifications(hook_module): + deferred_rows = launch_turn_rows("toolu_agent_a") + state = hook_module.SessionState( + pending_agent_turns=[ + { + "pending_tool_use_ids": ["toolu_agent_a", "toolu_agent_b"], + "rows": deferred_rows, + }, + ], + ) + + first_notification = make_notification_row( + "notif-a", "toolu_agent_a", "Result A", "2026-01-01T00:01:00.000Z" + ) + resolved, remaining = hook_module.resolve_deferred_agent_turns([first_notification], state) + + # One of two agents finished: the notification is captured, but the turn + # keeps waiting for the second agent instead of being emitted half-done. + assert resolved == [] + assert remaining == [] + assert len(state.pending_agent_turns) == 1 + assert state.pending_agent_turns[0]["pending_tool_use_ids"] == ["toolu_agent_b"] + assert state.pending_agent_turns[0]["resolved_tool_use_ids"] == ["toolu_agent_a"] + assert state.pending_agent_turns[0]["rows"][-1] is first_notification + + second_notification = make_notification_row( + "notif-b", "toolu_agent_b", "Result B", "2026-01-01T00:02:00.000Z" + ) + resolved, remaining = hook_module.resolve_deferred_agent_turns([second_notification], state) + + assert remaining == [] + assert state.pending_agent_turns == [] + assert len(resolved) == 1 + assert resolved[0] == deferred_rows + assert resolved[0][-2:] == [first_notification, second_notification] + + +def test_duplicate_notifications_for_same_agent_are_routed_to_the_deferred_turn(hook_module): + deferred_rows = launch_turn_rows("toolu_agent_a") + state = hook_module.SessionState( + pending_agent_turns=[ + { + "pending_tool_use_ids": ["toolu_agent_a"], + "rows": deferred_rows, + }, + ], + ) + notifications = [ + make_notification_row("notif-1", "toolu_agent_a", "Result", "2026-01-01T00:01:00.000Z"), + make_notification_row("notif-2", "toolu_agent_a", "Result (final)", "2026-01-01T00:01:01.000Z"), + ] + + resolved, remaining = hook_module.resolve_deferred_agent_turns(notifications, state) + + # Real transcripts often carry two notification rows per task; the second + # one must follow the first into the deferred turn instead of leaking into + # the current batch as an orphan row. + assert remaining == [] + assert state.pending_agent_turns == [] + assert len(resolved) == 1 + assert resolved[0][-2:] == notifications + + turns = hook_module.build_turns(resolved[0]) + assert len(turns) == 1 + assert turns[0].tool_results_by_id["toolu_agent_a"]["final_content"] == "Result (final)" + + +def test_resolve_ignores_non_notification_tool_use_xml(hook_module): + deferred_rows = [{"uuid": "deferred-row"}] + current_rows = [ + { + "type": "assistant", + "message": { + "role": "assistant", + "content": ( + "Quoted notification: " + "toolu_agent_a" + "" + ), + }, + }, + ] + state = hook_module.SessionState( + pending_agent_turns=[ + { + "pending_tool_use_ids": ["toolu_agent_a"], + "rows": deferred_rows, + }, + ], + ) + + resolved, remaining = hook_module.resolve_deferred_agent_turns(current_rows, state) + + assert resolved == [] + assert remaining == current_rows + assert state.pending_agent_turns == [ + { + "pending_tool_use_ids": ["toolu_agent_a"], + "rows": deferred_rows, + }, + ] + + +def test_multi_agent_turn_is_stored_once_with_all_waiting_tool_ids(hook_module): + rows = [{"uuid": "user-row"}, {"uuid": "assistant-row"}] + turn = hook_module.Turn( + user_msg=rows[0], + assistant_msgs=[ + { + "message": { + "content": [ + {"type": "tool_use", "id": "toolu_agent_a", "name": "Agent"}, + {"type": "tool_use", "id": "toolu_agent_b", "name": "Agent"}, + ], + }, + }, + ], + tool_results_by_id={ + "toolu_agent_a": { + "content": "Async agent launched successfully. agentId: agent-a output_file: /tmp/a You will be notified automatically" + }, + "toolu_agent_b": { + "content": "Async agent launched successfully. agentId: agent-b output_file: /tmp/b You will be notified automatically" + }, + }, + tool_use_timestamps_by_id={}, + injected_by_tool_id={}, + rows=rows, + ) + state = hook_module.SessionState() + + turns_to_emit = hook_module.get_turns_to_emit([turn], state) + + assert turns_to_emit == [] + assert len(state.pending_agent_turns) == 1 + assert state.pending_agent_turns[0]["pending_tool_use_ids"] == ["toolu_agent_a", "toolu_agent_b"] + assert state.pending_agent_turns[0]["rows"] == rows + + +def test_mid_turn_notification_does_not_corrupt_the_surrounding_turn(hook_module, tmp_path): + """Regression: resolving a deferred turn used to splice its rows into the + middle of the current batch, truncating the current turn at the splice + point and gluing its remaining assistant rows onto the rebuilt turn.""" + transcript = tmp_path / "transcript.jsonl" + state = hook_module.SessionState() + + # Hook run 1: turn 1 launches an async agent and ends -> deferred. + append_jsonl(transcript, launch_turn_rows("toolu_bg")) + turns, state = hook_module.get_new_turns_from_transcript(transcript, state) + assert hook_module.get_turns_to_emit(turns, state) == [] + assert len(state.pending_agent_turns) == 1 + + # Hook run 2: turn 2 is mid-flight when the notification lands between + # two of its assistant messages (the normal real-world shape). + append_jsonl(transcript, [ + make_user_row("user-2", "Next question.", "2026-01-01T00:05:00.000Z"), + make_assistant_row( + "assistant-3", "msg-3", + [{"type": "text", "text": "Working on it."}], + "2026-01-01T00:05:01.000Z", + ), + make_notification_row("notif-1", "toolu_bg", "Background result.", "2026-01-01T00:05:02.000Z"), + make_assistant_row( + "assistant-4", "msg-4", + [{"type": "text", "text": "Here is the answer."}], + "2026-01-01T00:05:03.000Z", + ), + ]) + turns, state = hook_module.get_new_turns_from_transcript(transcript, state) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert state.pending_agent_turns == [] + assert len(turns_to_emit) == 2 + + resolved_turn, current_turn = turns_to_emit + # The deferred turn is rebuilt intact, with the notification result attached. + assert resolved_turn.user_msg["uuid"] == "user-1" + assert [m["message"]["id"] for m in resolved_turn.assistant_msgs] == ["msg-1", "msg-2"] + assert resolved_turn.tool_results_by_id["toolu_bg"]["final_content"] == "Background result." + # The current turn keeps ALL of its assistant messages. + assert current_turn.user_msg["uuid"] == "user-2" + assert [m["message"]["id"] for m in current_turn.assistant_msgs] == ["msg-3", "msg-4"] + + +def test_notification_before_first_assistant_row_does_not_drop_the_turn(hook_module, tmp_path): + """Regression: a notification arriving between a user prompt and its first + assistant row used to erase that turn entirely (never traced).""" + transcript = tmp_path / "transcript.jsonl" + state = hook_module.SessionState() + + append_jsonl(transcript, launch_turn_rows("toolu_bg")) + turns, state = hook_module.get_new_turns_from_transcript(transcript, state) + assert hook_module.get_turns_to_emit(turns, state) == [] + + append_jsonl(transcript, [ + make_user_row("user-2", "Next question.", "2026-01-01T00:05:00.000Z"), + make_notification_row("notif-1", "toolu_bg", "Background result.", "2026-01-01T00:05:01.000Z"), + make_assistant_row( + "assistant-3", "msg-3", + [{"type": "text", "text": "Here is the answer."}], + "2026-01-01T00:05:02.000Z", + ), + ]) + turns, state = hook_module.get_new_turns_from_transcript(transcript, state) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert len(turns_to_emit) == 2 + resolved_turn, current_turn = turns_to_emit + assert resolved_turn.user_msg["uuid"] == "user-1" + assert resolved_turn.tool_results_by_id["toolu_bg"]["final_content"] == "Background result." + assert current_turn.user_msg["uuid"] == "user-2" + assert [m["message"]["id"] for m in current_turn.assistant_msgs] == ["msg-3"] + + +def sync_agent_turn_rows(tool_use_id: str = "toolu_sync", result_text: str = "Here is the final research report.") -> list[dict[str, Any]]: + return [ + make_user_row("user-1", "Run a subagent for research.", "2026-01-01T00:00:00.000Z"), + make_assistant_row( + "assistant-1", + "msg-1", + [{"type": "tool_use", "id": tool_use_id, "name": "Agent", + "input": {"description": "Research", "prompt": "Research now"}}], + "2026-01-01T00:00:01.000Z", + ), + make_agent_result_row( + "tool-result-1", + tool_use_id, + result_text, + "2026-01-01T00:00:02.000Z", + tool_use_result={"status": "completed", "agentId": "agent-sync", "totalDurationMs": 1000}, + ), + make_assistant_row( + "assistant-2", + "msg-2", + [{"type": "text", "text": "Summary of the research."}], + "2026-01-01T00:00:03.000Z", + ), + ] + + +def test_sync_agent_turn_is_emitted_immediately_despite_subagent_transcript(hook_module): + """Regression: a subagent transcript on disk used to defer the turn, but + sync agents never produce the task notification that releases it, so the + turn was stuck until SessionEnd (or lost entirely in killed sessions).""" + turns = hook_module.build_turns(sync_agent_turn_rows()) + state = hook_module.SessionState() + + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert len(turns_to_emit) == 1 + assert state.pending_agent_turns == [] + + +def test_structured_async_marker_defers_even_if_launch_text_changes(hook_module): + rows = [ + make_user_row("user-1", "Start a background agent.", "2026-01-01T00:00:00.000Z"), + make_assistant_row( + "assistant-1", + "msg-1", + [{"type": "tool_use", "id": "toolu_bg", "name": "Agent", "input": {}}], + "2026-01-01T00:00:01.000Z", + ), + make_agent_result_row( + "tool-result-1", + "toolu_bg", + "Background agent started.", # no recognizable launch prose + "2026-01-01T00:00:02.000Z", + tool_use_result={"status": "async_launched", "isAsync": True}, + ), + make_assistant_row( + "assistant-2", + "msg-2", + [{"type": "text", "text": "Working in the background."}], + "2026-01-01T00:00:03.000Z", + ), + ] + turns = hook_module.build_turns(rows) + state = hook_module.SessionState() + + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert turns_to_emit == [] + assert len(state.pending_agent_turns) == 1 + assert state.pending_agent_turns[0]["pending_tool_use_ids"] == ["toolu_bg"] + + +def test_structured_completed_marker_suppresses_launch_text_false_positive(hook_module): + # A sync agent whose RESULT quotes the launch prose (e.g. it inspected + # another transcript) must not be mistaken for an async launch. + quoted = 'The transcript said: "Async agent launched successfully. You will be notified automatically."' + turns = hook_module.build_turns(sync_agent_turn_rows(result_text=quoted)) + state = hook_module.SessionState() + + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert len(turns_to_emit) == 1 + assert state.pending_agent_turns == [] + + +def test_launch_text_fallback_defers_rows_without_tool_use_result(hook_module): + # Older Claude Code versions have no toolUseResult on the row; the prose + # heuristic keeps deferral working there. + rows = launch_turn_rows("toolu_bg") + rows[2].pop("toolUseResult", None) + turns = hook_module.build_turns(rows) + state = hook_module.SessionState() + + turns_to_emit = hook_module.get_turns_to_emit(turns, state) + + assert turns_to_emit == [] + assert len(state.pending_agent_turns) == 1 + + +def make_task_id_notification_row(uuid: str, task_id: str, result: str, timestamp: str) -> dict[str, Any]: + return { + "type": "user", + "timestamp": timestamp, + "uuid": uuid, + "origin": {"kind": "task-notification"}, + "message": { + "role": "user", + "content": ( + f"{task_id}" + f"{result}" + ), + }, + } + + +def write_subagent_meta(transcript: Path, agent_id: str, tool_use_id: str) -> None: + subagent_dir = transcript.with_suffix("") / "subagents" + subagent_dir.mkdir(parents=True, exist_ok=True) + (subagent_dir / f"agent-{agent_id}.meta.json").write_text( + json.dumps({"toolUseId": tool_use_id, "agentType": "general-purpose", "description": "bg"}), + encoding="utf-8", + ) + (subagent_dir / f"agent-{agent_id}.jsonl").write_text("", encoding="utf-8") + + +def test_unresolvable_notification_is_stashed_and_resolved_when_meta_appears(hook_module, tmp_path): + """Regression: a task-id-only notification arriving before the subagent's + meta.json exists (and with no open turn) was consumed and its result + permanently lost; the deferred turn was flushed without its final output.""" + transcript = tmp_path / "transcript.jsonl" + state = hook_module.SessionState() + + def run(flush=False): + sub_map = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + turns, new_state = hook_module.get_new_turns_from_transcript( + transcript, state, sub_map, flush_deferred_agent_turns=flush) + return hook_module.get_turns_to_emit(turns, new_state, flush_deferred_agent_turns=flush) + + append_jsonl(transcript, launch_turn_rows("toolu_bg")) + assert run() == [] + assert len(state.pending_agent_turns) == 1 + + # Notification lands alone in the next batch; no meta.json on disk yet. + append_jsonl(transcript, [ + make_task_id_notification_row("n1", "agent-late", "Late result.", "2026-01-01T00:01:00.000Z"), + ]) + assert run() == [] + assert len(state.pending_task_notifications) == 1 + + # meta.json appears late; the stashed notification resolves on this run. + write_subagent_meta(transcript, "agent-late", "toolu_bg") + append_jsonl(transcript, [ + make_user_row("user-2", "Next question.", "2026-01-01T00:02:00.000Z"), + make_assistant_row("assistant-3", "msg-3", + [{"type": "text", "text": "Answer."}], "2026-01-01T00:02:01.000Z"), + ]) + emitted = run() + + assert [t.user_msg["uuid"] for t in emitted] == ["user-1", "user-2"] + assert emitted[0].tool_results_by_id["toolu_bg"]["final_content"] == "Late result." + assert state.pending_agent_turns == [] + assert state.pending_task_notifications == [] + + +def test_stashed_notification_without_matching_deferred_turn_is_dropped(hook_module): + state = hook_module.SessionState( + pending_task_notifications=[ + make_notification_row("n1", "toolu_gone", "Result", "2026-01-01T00:01:00.000Z"), + ], + ) + + resolved, remaining = hook_module.resolve_deferred_agent_turns([], state) + + # Resolvable but nothing waits for it (turn already emitted): drop it + # instead of re-stashing forever. + assert resolved == [] + assert remaining == [] + assert state.pending_task_notifications == [] + + +def test_unresolved_notifications_are_dropped_at_session_end_flush(hook_module, tmp_path): + transcript = tmp_path / "transcript.jsonl" + state = hook_module.SessionState() + append_jsonl(transcript, launch_turn_rows("toolu_bg")) + append_jsonl(transcript, [ + make_task_id_notification_row("n1", "agent-unknown", "Result.", "2026-01-01T00:01:00.000Z"), + ]) + turns, state = hook_module.get_new_turns_from_transcript(transcript, state) + assert hook_module.get_turns_to_emit(turns, state) == [] + assert len(state.pending_task_notifications) == 1 + + flushed_turns, state = hook_module.get_new_turns_from_transcript( + transcript, state, flush_deferred_agent_turns=True) + flushed = hook_module.get_turns_to_emit(flushed_turns, state, flush_deferred_agent_turns=True) + + # The deferred turn is still flushed (without its final output) and the + # unresolvable notification does not linger in the state file. + assert len(flushed) == 1 + assert state.pending_task_notifications == [] + assert state.pending_agent_turns == [] + + +def test_stashed_notifications_are_bounded(hook_module): + state = hook_module.SessionState() + rows = [ + make_task_id_notification_row(f"n{i}", f"agent-{i}", "r", "2026-01-01T00:01:00.000Z") + for i in range(hook_module.MAX_PENDING_TASK_NOTIFICATIONS + 10) + ] + + hook_module.resolve_deferred_agent_turns(rows, state) + + assert len(state.pending_task_notifications) == hook_module.MAX_PENDING_TASK_NOTIFICATIONS + # the newest notifications win + assert state.pending_task_notifications[-1]["uuid"] == rows[-1]["uuid"] + + +def test_session_state_round_trips_stashed_notifications(hook_module): + notification = make_notification_row("n1", "toolu_bg", "Result", "2026-01-01T00:01:00.000Z") + state = hook_module.SessionState(pending_task_notifications=[notification]) + global_state: dict[str, Any] = {} + + hook_module.update_session_state(global_state, "key", state) + restored = hook_module.get_session_state(global_state, "key") + + assert restored.pending_task_notifications == [notification] diff --git a/tests/unit/test_hook_payload.py b/tests/unit/test_hook_payload.py new file mode 100644 index 0000000..befe5f7 --- /dev/null +++ b/tests/unit/test_hook_payload.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import io +import sys + + +def test_read_hook_payload_accepts_json_object(hook_module, monkeypatch): + monkeypatch.setattr(sys, "stdin", io.StringIO('{"sessionId":"s1"}')) + + assert hook_module.read_hook_payload() == {"sessionId": "s1"} + + +def test_read_hook_payload_rejects_non_object(hook_module, monkeypatch): + monkeypatch.setattr(sys, "stdin", io.StringIO('["not", "an", "object"]')) + + assert hook_module.read_hook_payload() == {} + + +def test_extract_session_id_and_transcript_path_supports_known_payload_shapes( + hook_module, + tmp_path, +): + transcript = tmp_path / "session.jsonl" + transcript.write_text("", encoding="utf-8") + + session_id, transcript_path = hook_module.extract_session_id_and_transcript_path( + {"sessionId": "camel", "transcriptPath": str(transcript)} + ) + assert session_id == "camel" + assert transcript_path == transcript.resolve() + + session_id, transcript_path = hook_module.extract_session_id_and_transcript_path( + {"session_id": "snake", "transcript_path": str(transcript)} + ) + assert session_id == "snake" + assert transcript_path == transcript.resolve() + + session_id, transcript_path = hook_module.extract_session_id_and_transcript_path( + {"session": {"id": "nested"}, "transcript": {"path": str(transcript)}} + ) + assert session_id == "nested" + assert transcript_path == transcript.resolve() + + +def test_get_session_id_and_transcript_path_fails_open_for_missing_file(hook_module, tmp_path): + missing_transcript = tmp_path / "missing.jsonl" + + assert hook_module.get_session_id_and_transcript_path( + {"sessionId": "s1", "transcriptPath": str(missing_transcript)} + ) is None + + +def test_session_end_payload_detection_supports_both_key_styles(hook_module): + assert hook_module.is_session_end_hook_payload({"hook_event_name": "SessionEnd"}) + assert hook_module.is_session_end_hook_payload({"hookEventName": "SessionEnd"}) + assert not hook_module.is_session_end_hook_payload({"hook_event_name": "Stop"}) diff --git a/tests/unit/test_langfuse_payload_builders.py b/tests/unit/test_langfuse_payload_builders.py new file mode 100644 index 0000000..75a7e1d --- /dev/null +++ b/tests/unit/test_langfuse_payload_builders.py @@ -0,0 +1,168 @@ +from __future__ import annotations + + +def test_generation_input_tracks_user_tool_and_async_tool_contexts(hook_module): + assert hook_module.build_generation_input(0, "hello", [], []) == { + "role": "user", + "content": "hello", + } + assert hook_module.build_generation_input( + 1, + "ignored", + [{"tool_use_id": "toolu_read", "output": "file"}], + [], + ) == {"role": "tool", "tool_results": [{"tool_use_id": "toolu_read", "output": "file"}]} + assert hook_module.build_generation_input( + 1, + "ignored", + [], + [{"tool_result": {"tool_use_id": "toolu_agent", "output": "done"}}], + ) == {"role": "tool", "tool_results": [{"tool_use_id": "toolu_agent", "output": "done"}]} + assert hook_module.build_generation_input(1, "ignored", [], []) is None + + +def test_generation_input_merges_previous_batch_and_ready_async_results(hook_module): + # Regression: a previous tool batch used to shadow async results that + # became ready at the same time; they are popped from the pending list + # when consumed, so dropping them here lost them for good. + assert hook_module.build_generation_input( + 1, + "ignored", + [{"tool_use_id": "toolu_read", "output": "file"}], + [{"tool_result": {"tool_use_id": "toolu_agent", "output": "done"}}], + ) == { + "role": "tool", + "tool_results": [ + {"tool_use_id": "toolu_read", "output": "file"}, + {"tool_use_id": "toolu_agent", "output": "done"}, + ], + } + + +def test_generation_output_lists_tool_calls_without_full_tool_input(hook_module): + output = hook_module.build_generation_output( + "I will read it.", + [{"id": "toolu_read", "name": "Read", "input": {"file_path": "README.md"}}], + ) + + assert output == { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [{"id": "toolu_read", "name": "Read"}], + } + + +def test_tool_result_payload_preserves_initial_and_final_async_outputs(hook_module): + result = hook_module.get_tool_result_for_observation( + { + "content": "Async agent launched successfully.", + "timestamp": "2026-01-01T00:00:01.000Z", + "final_content": "Final async output", + "final_timestamp": "2026-01-01T00:00:05.000Z", + } + ) + + assert result.output == "Async agent launched successfully." + assert result.final_output == "Final async output" + assert result.result_timestamp.isoformat() == "2026-01-01T00:00:01+00:00" + assert result.final_result_timestamp.isoformat() == "2026-01-01T00:00:05+00:00" + + +def test_tool_metadata_uses_short_subagent_transcript_paths(hook_module, tmp_path): + metadata = hook_module.build_tool_metadata( + "Agent", + "toolu_agent", + None, + hook_module.ToolResultForObservation(output_meta={"truncated": False}), + { + "agent_type": "general-purpose", + "description": "Summarize docs", + "path": tmp_path / "agent-a123.jsonl", + }, + ) + + assert metadata["tool_name"] == "Agent" + assert metadata["tool_id"] == "toolu_agent" + assert metadata["subagent_type"] == "general-purpose" + assert metadata["subagent_description"] == "Summarize docs" + assert metadata["subagent_transcript_path"] == "agent-a123.jsonl" + + +def test_trace_metadata_includes_session_turn_project_and_branch( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, +): + rows = read_fixture_jsonl(fixture_transcript_path("simple_turn")) + turn = hook_module.build_turns(rows)[0] + user_text, user_text_meta = hook_module.truncate_text( + hook_module.extract_text_from_content(hook_module.get_content_from_row(turn.user_msg)) + ) + + metadata = hook_module.build_trace_metadata( + "12345678-abcd-4000-8000-123456789abc", + 7, + turn, + fixture_transcript_path("simple_turn"), + user_text_meta, + ) + + assert user_text == "Say hello." + assert metadata["session_id"] == "12345678-abcd-4000-8000-123456789abc" + assert metadata["turn_number"] == 7 + assert metadata["transcript_path"] == "transcript.jsonl" + assert metadata["cwd"] == "/repo" + assert metadata["git_branch"] == "feature/test" + + +def test_async_final_result_reaches_generation_input_despite_tool_batch( + hook_module, + fake_langfuse, +): + """Regression: when a normal tool result and a ready async agent result + both precede an assistant message, the async final result vanished from + every generation input.""" + rows = [ + {"type": "user", "timestamp": "2026-01-01T00:00:00.000Z", "uuid": "u1", + "message": {"role": "user", "content": "Start agent then run a command."}}, + {"type": "assistant", "timestamp": "2026-01-01T00:00:01.000Z", "uuid": "a1", + "message": {"id": "msg-1", "role": "assistant", "model": "claude-test", + "content": [{"type": "tool_use", "id": "toolu_bg", "name": "Agent", "input": {}}]}}, + {"type": "user", "timestamp": "2026-01-01T00:00:02.000Z", "uuid": "tr1", + "toolUseResult": {"status": "async_launched", "isAsync": True}, + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_bg", + "content": [{"type": "text", "text": "Async agent launched successfully."}]}]}}, + {"type": "assistant", "timestamp": "2026-01-01T00:00:03.000Z", "uuid": "a2", + "message": {"id": "msg-2", "role": "assistant", "model": "claude-test", + "content": [{"type": "tool_use", "id": "toolu_bash", "name": "Bash", + "input": {"command": "ls"}}]}}, + {"type": "user", "timestamp": "2026-01-01T00:00:05.000Z", "uuid": "tr2", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_bash", + "content": [{"type": "text", "text": "bash output"}]}]}}, + {"type": "user", "timestamp": "2026-01-01T00:00:06.000Z", "uuid": "n1", + "origin": {"kind": "task-notification"}, + "message": {"role": "user", "content": ( + "toolu_bg" + "Async final result.")}}, + {"type": "assistant", "timestamp": "2026-01-01T00:00:07.000Z", "uuid": "a3", + "message": {"id": "msg-3", "role": "assistant", "model": "claude-test", + "content": [{"type": "text", "text": "All done."}]}}, + ] + turn = hook_module.build_turns(rows)[0] + + hook_module.emit_turn_observations(fake_langfuse, None, turn, None) + + generation_inputs = [ + observation.kwargs.get("input") + for observation in fake_langfuse.observations + if observation.as_type == "generation" + ] + assert len(generation_inputs) == 3 + final_generation_results = generation_inputs[2]["tool_results"] + assert [result["tool_use_id"] for result in final_generation_results] == [ + "toolu_bash", + "toolu_bg", + ] + assert final_generation_results[1]["output"] == "Async final result." diff --git a/tests/unit/test_subagent_discovery.py b/tests/unit/test_subagent_discovery.py new file mode 100644 index 0000000..78f9e3a --- /dev/null +++ b/tests/unit/test_subagent_discovery.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json + + +def test_discovers_subagent_transcripts_by_parent_tool_use_id( + hook_module, + fixture_transcript_path, +): + transcript = fixture_transcript_path("async_agent_completed") + + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + + assert set(subagents) == {"toolu_agent_complete"} + subagent = subagents["toolu_agent_complete"] + assert subagent["agent_id"] == "agent-complete" + assert subagent["agent_type"] == "general-purpose" + assert subagent["description"] == "Summarize docs" + assert subagent["path"].name == "agent-agent-complete.jsonl" + + +def test_discovers_nested_subagent_metadata_seen_in_real_claude_code_transcripts( + hook_module, + fixture_transcript_path, +): + transcript = fixture_transcript_path("nested_subagents") + + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + + assert set(subagents) == {"toolu_outer_agent", "toolu_inner_agent"} + assert subagents["toolu_outer_agent"]["agent_id"] == "outer-agent" + assert subagents["toolu_inner_agent"]["agent_id"] == "inner-agent" + assert subagents["toolu_inner_agent"]["agent_type"] == "fork" + + +def test_ignores_bad_or_incomplete_subagent_metadata(hook_module, tmp_path): + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("", encoding="utf-8") + subagent_dir = tmp_path / "transcript" / "subagents" + subagent_dir.mkdir(parents=True) + (subagent_dir / "agent-bad.meta.json").write_text("{not-json", encoding="utf-8") + (subagent_dir / "agent-missing-jsonl.meta.json").write_text( + json.dumps({"toolUseId": "toolu_missing"}), + encoding="utf-8", + ) + (subagent_dir / "agent-missing-tool-id.meta.json").write_text( + json.dumps({"description": "No tool id"}), + encoding="utf-8", + ) + (subagent_dir / "agent-missing-tool-id.jsonl").write_text("", encoding="utf-8") + + assert hook_module.get_subagent_transcripts_by_tool_use_id(transcript) == {} diff --git a/tests/unit/test_subagent_observations.py b/tests/unit/test_subagent_observations.py new file mode 100644 index 0000000..596043c --- /dev/null +++ b/tests/unit/test_subagent_observations.py @@ -0,0 +1,36 @@ +from __future__ import annotations + + +def test_subagent_observations_skip_malformed_jsonl_lines( + hook_module, + fake_langfuse, + fixture_transcript_path, + tmp_path, +): + transcript = fixture_transcript_path("async_agent_completed") + source_path = transcript.with_suffix("") / "subagents" / "agent-agent-complete.jsonl" + source_lines = source_path.read_text(encoding="utf-8").splitlines() + malformed_path = tmp_path / "agent-bad-line.jsonl" + malformed_path.write_text( + "\n".join([source_lines[0], "{bad-json", *source_lines[1:]]) + "\n", + encoding="utf-8", + ) + + end_timestamp = hook_module.emit_subagent_observations( + fake_langfuse, + None, + { + "path": malformed_path, + "description": "Summarize docs", + "agent_type": "general-purpose", + }, + None, + ) + + assert end_timestamp.isoformat() == "2026-01-01T00:02:04.500000+00:00" + assert [observation.name for observation in fake_langfuse.observations] == [ + "Subagent: Summarize docs", + "Subagent LLM Call 1", + "Tool: ToolSearch", + "Subagent LLM Call 2", + ] diff --git a/tests/unit/test_task_notifications.py b/tests/unit/test_task_notifications.py new file mode 100644 index 0000000..6c59a5a --- /dev/null +++ b/tests/unit/test_task_notifications.py @@ -0,0 +1,100 @@ +from __future__ import annotations + + +def test_task_notification_detection_supports_queue_and_origin_rows(hook_module): + queue_row = { + "type": "queue-operation", + "content": "a1done", + } + origin_row = { + "type": "user", + "origin": {"kind": "task-notification"}, + "message": {"role": "user", "content": "plain notification text"}, + } + + assert hook_module.is_task_notification_row(queue_row) + assert hook_module.is_task_notification_row(origin_row) + + +def test_task_notification_extracts_tool_use_id_task_id_and_result(hook_module): + row = { + "type": "user", + "message": { + "role": "user", + "content": ( + "agent-1" + "toolu_agent" + "Agent result" + ), + }, + } + + assert hook_module.get_task_id_from_task_notification(row) == "agent-1" + assert hook_module.get_tool_use_id_from_task_notification(row) == "toolu_agent" + assert hook_module.get_result_from_task_notification(row) == "Agent result" + + +def test_origin_task_notification_extracts_tool_use_id_after_system_prefix(hook_module): + row = { + "type": "user", + "origin": {"kind": "task-notification"}, + "message": { + "role": "user", + "content": ( + "[SYSTEM NOTIFICATION - NOT USER INPUT]\n" + "This is an automated background-task event.\n\n" + "agent-1" + "toolu_agent" + "Agent result" + ), + }, + } + + assert hook_module.is_task_notification_row(row) + assert hook_module.get_tool_use_id_for_task_notification(row) == "toolu_agent" + + +def test_non_task_notification_does_not_extract_tool_use_id(hook_module): + row = { + "type": "assistant", + "message": { + "role": "assistant", + "content": ( + "The transcript included: " + "toolu_agent" + "" + ), + }, + } + + assert not hook_module.is_task_notification_row(row) + assert hook_module.get_tool_use_id_for_task_notification(row) is None + + +def test_task_id_fallback_maps_notification_to_tool_use_id( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, +): + transcript = fixture_transcript_path("task_notification_task_id_fallback") + subagents = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + task_id_to_tool_use_id = hook_module.get_task_id_to_tool_use_id(subagents) + rows = read_fixture_jsonl(transcript) + notification = next( + row + for row in rows + if hook_module.is_task_notification_row(row) + and hook_module.get_task_id_from_task_notification(row) == "task-fallback" + ) + + assert hook_module.get_tool_use_id_from_task_notification(notification) is None + assert hook_module.get_tool_use_id_for_task_notification( + notification, + task_id_to_tool_use_id, + ) == "toolu_task_fallback" + + turns = hook_module.build_turns(rows, task_id_to_tool_use_id) + assert len(turns) == 1 + result = turns[0].tool_results_by_id["toolu_task_fallback"] + assert result["final_content"] == "Fallback result through task id." + assert result["final_timestamp"] == "2026-01-01T00:04:03.000Z" diff --git a/tests/unit/test_transcript_reader.py b/tests/unit/test_transcript_reader.py new file mode 100644 index 0000000..0bc9898 --- /dev/null +++ b/tests/unit/test_transcript_reader.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import json + + +def test_read_new_jsonl_reads_once_then_advances_offset( + hook_module, + fixture_transcript_path, +): + transcript = fixture_transcript_path("simple_turn") + state = hook_module.SessionState() + + rows, state = hook_module.read_new_jsonl(transcript, state) + assert len(rows) == 3 + assert state.offset == transcript.stat().st_size + assert state.buffer == "" + + rows, state = hook_module.read_new_jsonl(transcript, state) + assert rows == [] + assert state.offset == transcript.stat().st_size + + +def test_read_new_jsonl_preserves_partial_line_between_reads(hook_module, tmp_path): + transcript = tmp_path / "partial.jsonl" + first_row = {"type": "user", "message": {"role": "user", "content": "hello"}} + second_row = {"type": "assistant", "message": {"role": "assistant", "content": []}} + transcript.write_text(json.dumps(first_row), encoding="utf-8") + + state = hook_module.SessionState() + rows, state = hook_module.read_new_jsonl(transcript, state) + assert rows == [] + assert state.buffer == json.dumps(first_row) + + with transcript.open("a", encoding="utf-8") as fh: + fh.write("\n") + fh.write(json.dumps(second_row)) + fh.write("\n") + + rows, state = hook_module.read_new_jsonl(transcript, state) + assert rows == [first_row, second_row] + assert state.buffer == "" + + +def test_read_new_jsonl_restarts_when_file_shrinks(hook_module, tmp_path): + transcript = tmp_path / "rotated.jsonl" + first = {"type": "user", "message": {"role": "user", "content": "old transcript content"}} + second = {"type": "user", "message": {"role": "user", "content": "new"}} + transcript.write_text(json.dumps(first) + "\n", encoding="utf-8") + + state = hook_module.SessionState() + rows, state = hook_module.read_new_jsonl(transcript, state) + assert rows == [first] + + transcript.write_text(json.dumps(second) + "\n", encoding="utf-8") + rows, state = hook_module.read_new_jsonl(transcript, state) + assert rows == [second] + assert state.offset == transcript.stat().st_size diff --git a/tests/unit/test_turn_assembly.py b/tests/unit/test_turn_assembly.py new file mode 100644 index 0000000..22eeb85 --- /dev/null +++ b/tests/unit/test_turn_assembly.py @@ -0,0 +1,93 @@ +from __future__ import annotations + + +def test_build_turns_merges_split_assistant_rows( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, +): + rows = read_fixture_jsonl(fixture_transcript_path("simple_turn")) + + turns = hook_module.build_turns(rows) + + assert len(turns) == 1 + turn = turns[0] + assert hook_module.extract_text_from_content(hook_module.get_content_from_row(turn.user_msg)) == "Say hello." + assert len(turn.assistant_msgs) == 1 + content = hook_module.get_content_from_row(turn.assistant_msgs[0]) + assert [block["text"] for block in content] == ["Hello", "from Claude Code."] + assert hook_module.get_usage_details_from_row(turn.assistant_msgs[0]) == { + "input": 10, + "output": 5, + } + + +def test_build_turns_associates_tool_results_with_tool_use( + hook_module, + fixture_transcript_path, + read_fixture_jsonl, +): + rows = read_fixture_jsonl(fixture_transcript_path("tool_turn")) + + turns = hook_module.build_turns(rows) + + assert len(turns) == 1 + turn = turns[0] + assert [len(hook_module.get_tool_use_blocks(hook_module.get_content_from_row(row))) for row in turn.assistant_msgs] == [1, 0] + assert turn.tool_results_by_id["toolu_read_1"] == { + "content": "# Example", + "timestamp": "2026-01-01T00:01:02.000Z", + } + assert turn.tool_use_timestamps_by_id["toolu_read_1"] == "2026-01-01T00:01:01.000Z" + + +def test_meta_rows_do_not_start_or_flush_turns(hook_module): + rows = [ + { + "type": "user", + "timestamp": "2026-01-01T00:00:00.000Z", + "message": {"role": "user", "content": "Use a skill."}, + }, + { + "type": "assistant", + "timestamp": "2026-01-01T00:00:01.000Z", + "message": { + "id": "msg-skill", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_skill", + "name": "Skill", + "input": {"skill": "example"}, + } + ], + }, + }, + { + "type": "user", + "isMeta": True, + "sourceToolUseID": "toolu_skill", + "message": {"role": "user", "content": "Injected skill instructions"}, + }, + { + "type": "user", + "timestamp": "2026-01-01T00:00:02.000Z", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_skill", + "content": "Skill result", + } + ], + }, + }, + ] + + turns = hook_module.build_turns(rows) + + assert len(turns) == 1 + assert turns[0].injected_by_tool_id == {"toolu_skill": "Injected skill instructions"} + assert turns[0].tool_results_by_id["toolu_skill"]["content"] == "Skill result" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..921ca53 --- /dev/null +++ b/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "claude-observability-plugin" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0,<9" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]