From dcacf196ae9bd6254878734706f550ca50d29633 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 11:17:05 +0200 Subject: [PATCH 01/30] fix(hook): trace Claude Code subagent transcripts --- hooks/langfuse_hook.py | 581 +++++++++++++++++++++++++++++++---------- 1 file changed, 436 insertions(+), 145 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 6681cc6..d78b527 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -17,7 +17,7 @@ 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 @@ -342,13 +342,18 @@ 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: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState: s = global_state.get(key, {}) + pending = s.get("pending_agent_turns") + if not isinstance(pending, dict): + pending = {} 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, ) def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None: @@ -356,6 +361,7 @@ def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState "offset": ss.offset, "buffer": ss.buffer, "turn_count": ss.turn_count, + "pending_agent_turns": ss.pending_agent_turns or {}, "updated": datetime.now(timezone.utc).isoformat(), } @@ -414,9 +420,91 @@ 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]] + +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 get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str]: + notification_text = extract_text(get_content_from_row(row)).lstrip() + if not notification_text.startswith(""): + return None + 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_result_from_task_notification_row(row: Dict[str, Any]) -> str: + notification_text = extract_text(get_content_from_row(row)) + result = _extract_xml_tag_value(notification_text, "result") + return result if result is not None else notification_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 + tool_result_content = tool_result_entry.get("content") if isinstance(tool_result_entry, dict) else None + tool_result_text = tool_result_content if isinstance(tool_result_content, str) else json.dumps(tool_result_content, ensure_ascii=False) + if "Async agent launched successfully" in tool_result_text: + tool_use_ids.append(tool_use_id) + return tool_use_ids + +def prepend_deferred_agent_turn_rows(messages: List[Dict[str, Any]], ss: SessionState) -> List[Dict[str, Any]]: + if not ss.pending_agent_turns: + return messages + rows: List[Dict[str, Any]] = [] + for row in messages: + tool_use_id = get_tool_use_id_from_task_notification(row) + if tool_use_id and tool_use_id in ss.pending_agent_turns: + rows.extend(ss.pending_agent_turns.pop(tool_use_id)) + rows.append(row) + return rows + +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 + + subagent_transcripts_by_tool_use_id[tool_use_id] = { + "path": jsonl_path, + "agent_type": metadata.get("agentType"), + "description": metadata.get("description"), + } + return subagent_transcripts_by_tool_use_id def merge_assistant_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: """ @@ -461,10 +549,12 @@ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]: 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 + tool_use_timestamps_by_id: Dict[str, Any] = {} # tool_use_id -> row timestamp injected_by_tool_id: Dict[str, str] = {} # tool_use_id -> injected text (skill instructions) + current_rows: List[Dict[str, Any]] = [] 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 + nonlocal current_turn_user_row, assistant_message_ids, assistant_rows_by_message_id, tool_results_by_id, tool_use_timestamps_by_id, injected_by_tool_id, current_rows, turns if current_turn_user_row is None: return if not assistant_rows_by_message_id: @@ -482,7 +572,9 @@ def flush_turn(): user_msg=current_turn_user_row, assistant_msgs=merged_assistant_rows, tool_results_by_id=dict(tool_results_by_id), + tool_use_timestamps_by_id=dict(tool_use_timestamps_by_id), injected_by_tool_id=dict(injected_by_tool_id), + rows=list(current_rows), )) for row in messages: @@ -498,12 +590,14 @@ def flush_turn(): txt = extract_text(get_content_from_row(row)) if txt: injected_by_tool_id[str(src)] = txt + current_rows.append(row) continue role = get_user_or_assistant_role_from_row(row) # tool_result rows show up as role=user with content blocks of type tool_result if is_tool_result(row): + current_rows.append(row) row_ts = row.get("timestamp") for tr in get_tool_result_blocks(get_content_from_row(row)): tid = tr.get("tool_use_id") @@ -511,6 +605,24 @@ def flush_turn(): tool_results_by_id[str(tid)] = {"content": tr.get("content"), "timestamp": row_ts} continue + task_tid = get_tool_use_id_from_task_notification(row) + if task_tid: + if current_turn_user_row is not None: + existing_result = tool_results_by_id.get(task_tid) + if isinstance(existing_result, dict): + existing_result["final_content"] = get_result_from_task_notification_row(row) + existing_result["final_timestamp"] = row.get("timestamp") + else: + tool_results_by_id[task_tid] = { + "content": get_result_from_task_notification_row(row), + "timestamp": row.get("timestamp"), + } + current_rows.append(row) + else: + current_turn_user_row = row + current_rows = [row] + continue + if role == "user": # new user message -> finalize previous turn flush_turn() @@ -520,7 +632,9 @@ def flush_turn(): assistant_message_ids = [] assistant_rows_by_message_id = {} tool_results_by_id = {} + tool_use_timestamps_by_id = {} injected_by_tool_id = {} + current_rows = [row] continue if role == "assistant": @@ -533,6 +647,11 @@ def flush_turn(): assistant_message_ids.append(message_id) assistant_rows_by_message_id[message_id] = [] 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: + tool_use_timestamps_by_id.setdefault(str(tool_use_id), row.get("timestamp")) + current_rows.append(row) continue # ignore unknown rows @@ -616,8 +735,296 @@ 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_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 emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, + start_ts: Optional[datetime], + generation_prefix: str = "Claude Generation", + 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(get_content_from_row(turn.user_msg))) + prev_ts = start_ts + prev_tool_results: List[Dict[str, Any]] = [] + pending_async_tool_results: List[Dict[str, Any]] = [] + pending_subagents: List[Dict[str, Any]] = [] + latest_end = start_ts + + 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)) + ready_subagents: List[Dict[str, Any]] = [] + if idx > 0 and pending_subagents: + still_pending_subagents: List[Dict[str, Any]] = [] + for pending_subagent in pending_subagents: + ready_ts = pending_subagent.get("ready_timestamp") + if isinstance(ready_ts, datetime) and (am_ts is None or ready_ts <= am_ts): + ready_subagents.append(pending_subagent) + else: + still_pending_subagents.append(pending_subagent) + pending_subagents = still_pending_subagents + for ready_subagent in ready_subagents: + subagent_end_ts = emit_subagent_observations( + langfuse, + parent_otel_span, + ready_subagent["subagent"], + ready_subagent.get("start_timestamp"), + ) + latest_end = _get_latest_timestamp(latest_end, subagent_end_ts) + + ready_async_tool_results: List[Dict[str, Any]] = [] + if idx > 0 and pending_async_tool_results: + still_pending: List[Dict[str, Any]] = [] + for async_result in pending_async_tool_results: + async_ts = async_result.get("timestamp") + if isinstance(async_ts, datetime) and (am_ts is None or async_ts <= am_ts): + ready_async_tool_results.append(async_result) + else: + still_pending.append(async_result) + pending_async_tool_results = still_pending + if ready_async_tool_results: + ready_ts = _get_latest_timestamp(*[ + result.get("timestamp") for result in ready_async_tool_results + if isinstance(result.get("timestamp"), datetime) + ]) + prev_ts = _get_latest_timestamp(prev_ts, ready_ts) + + if idx == 0: + gen_input: Any = {"role": "user", "content": user_text} + elif prev_tool_results: + gen_input = {"role": "tool", "tool_results": prev_tool_results} + elif ready_async_tool_results: + gen_input = { + "role": "tool", + "tool_results": [result["tool_result"] for result in ready_async_tool_results], + } + else: + gen_input = None + + gen_tool_calls = [] + for tu in tool_uses: + gen_tool_calls.append({ + "id": tu.get("id"), + "name": tu.get("name"), + }) + + 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( + langfuse, + name=f"{generation_prefix} {idx + 1}", + as_type="generation", + start_time=prev_ts or am_ts, + parent_otel_span=parent_otel_span, + **gen_kwargs, + ) + + 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")) + final_out_raw = tr_entry.get("final_content") + if final_out_raw is not None: + final_out_str = final_out_raw if isinstance(final_out_raw, str) else json.dumps(final_out_raw, ensure_ascii=False) + final_out_trunc, final_out_meta = truncate_text(final_out_str) + final_tr_ts = parse_timestamp(tr_entry.get("final_timestamp")) + else: + final_out_trunc, final_out_meta, final_tr_ts = None, None, None + else: + out_trunc, out_meta, tr_ts = None, None, None + final_out_trunc, final_out_meta, final_tr_ts = None, None, None + + tool_output_trunc = out_trunc + tool_output_meta = out_meta + tool_output: Any = tool_output_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": tool_output_trunc, "injected_instructions": injected_trunc} + + sub = subagent_transcripts_by_tool_use_id.get(tid) if subagent_transcripts_by_tool_use_id and tid else None + tool_meta: Dict[str, Any] = { + "tool_name": tname, + "tool_id": tid, + "input_meta": tinput_meta, + "output_meta": tool_output_meta, + } + if sub: + tool_meta.update({ + "subagent_type": sub.get("agent_type"), + "subagent_description": sub.get("description"), + "subagent_transcript_path": str(sub.get("path")), + }) + + tool_use_ts = parse_timestamp(turn.tool_use_timestamps_by_id.get(tid)) or am_ts + tool_span = _start_backdated( + langfuse, + name=f"Tool: {tname}", + as_type="tool", + start_time=tool_use_ts, + parent_otel_span=parent_otel_span, + input=tinput, + metadata=tool_meta, + ) + tool_span.update(output=tool_output) + + subagent_end_ts = None + if sub: + if final_tr_ts is not None: + pending_subagents.append({ + "subagent": sub, + "start_timestamp": tool_use_ts, + "ready_timestamp": final_tr_ts, + }) + else: + subagent_end_ts = emit_subagent_observations(langfuse, parent_otel_span, sub, tool_use_ts) + + tool_end_ts = _get_latest_timestamp(tr_ts, tool_use_ts) + handoff_ts = tr_ts or final_tr_ts or subagent_end_ts or am_ts + if handoff_ts is not None: + batch_result_ts.append(handoff_ts) + tool_span.end(end_time=_to_ns(tool_end_ts)) + latest_end = _get_latest_timestamp(latest_end, tool_end_ts, subagent_end_ts) + + batch_tool_results.append({ + "tool_use_id": tid, + "tool_name": tname, + "output": out_trunc, + }) + if final_tr_ts is not None and final_out_trunc is not None: + pending_async_tool_results.append({ + "timestamp": final_tr_ts, + "tool_result": { + "tool_use_id": tid, + "tool_name": tname, + "output": final_out_trunc, + }, + }) + + 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)) + latest_end = _get_latest_timestamp(latest_end, gen_end_ts) + + prev_tool_results = batch_tool_results + if batch_result_ts: + prev_ts = max(batch_result_ts) + elif am_ts is not None: + prev_ts = am_ts + + for pending_subagent in pending_subagents: + subagent_end_ts = emit_subagent_observations( + langfuse, + parent_otel_span, + pending_subagent["subagent"], + pending_subagent.get("start_timestamp"), + ) + latest_end = _get_latest_timestamp(latest_end, subagent_end_ts) + + return latest_end + + +def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, + subagent: Dict[str, Any], + start_ts: Optional[datetime]) -> Optional[datetime]: + path = subagent.get("path") + if not isinstance(path, Path): + return start_ts + try: + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + except Exception as e: + info(f"subagent transcript read failed ({path}): {type(e).__name__}: {e}") + return start_ts + + turns = build_turns(rows) + if not turns: + return start_ts + + first_turn = turns[0] + subagent_start_ts = parse_timestamp(first_turn.user_msg) or start_ts + subagent_input_text, subagent_input_meta = truncate_text(extract_text(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(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_ts, + parent_otel_span=parent_otel_span, + input={"role": "user", "content": subagent_input_text}, + metadata={ + "agent_type": subagent.get("agent_type"), + "description": description, + "transcript_path": str(path), + "user_text": subagent_input_meta, + }, + ) + + latest_end = subagent_start_ts + prev_start = subagent_start_ts + for turn in turns: + latest = emit_observations( + langfuse, + subagent_span._otel_span, + turn, + prev_start, + generation_prefix="Subagent Generation", + subagent_transcripts_by_tool_use_id=None, + ) + latest_end = _get_latest_timestamp(latest_end, latest) + if latest is not None: + prev_start = latest + + subagent_span.update(output={"role": "assistant", "content": subagent_output_text}) + subagent_span.end(end_time=_to_ns(_get_latest_timestamp(latest_end, subagent_start_ts))) + + return latest_end + + def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, transcript_path: Path, - user_id: Optional[str] = None) -> None: + user_id: Optional[str] = None, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None) -> None: user_text_raw = extract_text(get_content_from_row(turn.user_msg)) user_text, user_text_meta = truncate_text(user_text_raw) @@ -670,147 +1077,15 @@ 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( - 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, - ) - - # 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)) - - 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)) - - # Carry this batch's results into the next generation's input. - prev_tool_results = batch_tool_results - - # 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 - + obs_end_ts = emit_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_ns(turn_end_ts or last_assistant_ts or user_ts)) + trace_span.end(end_time=_to_ns(_get_latest_timestamp(turn_end_ts, last_assistant_ts, obs_end_ts, user_ts))) # ----------------- Main ----------------- def main() -> int: @@ -855,19 +1130,35 @@ def main() -> int: save_state(state) return 0 + msgs = prepend_deferred_agent_turn_rows(msgs, ss) turns = build_turns(msgs) if not turns: write_session_state(state, key, ss) save_state(state) return 0 + 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_to_emit: List[Turn] = [] + for t in turns: + pending_agent_tool_use_ids = get_pending_agent_tool_use_ids(t) + if pending_agent_tool_use_ids: + for tool_use_id in pending_agent_tool_use_ids: + ss.pending_agent_turns[tool_use_id] = t.rows + debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") + continue + turns_to_emit.append(t) + # emit turns emitted = 0 - for t in turns: + for t in turns_to_emit: emitted += 1 turn_num = ss.turn_count + emitted try: - emit_turn(langfuse, session_id, turn_num, t, transcript_path, user_id=user_id) + emit_turn(langfuse, session_id, turn_num, t, 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. From a1d9d7d82741d8d85bd791a15aef3c462ffaa560 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 13:02:35 +0200 Subject: [PATCH 02/30] refactor(hook): centralize Langfuse setup --- hooks/langfuse_hook.py | 60 ++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index d78b527..de41ccc 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -23,19 +23,20 @@ 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 +49,43 @@ def _opt(name: str) -> str: except ValueError: MAX_CHARS = 20000 + +@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 @@ -1092,12 +1130,12 @@ 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 + config = get_langfuse_config() + if config is None: + return 0 - if not public_key or not secret_key: + langfuse = create_langfuse_client(config) + if langfuse is None: return 0 payload = read_hook_payload() @@ -1112,12 +1150,6 @@ def main() -> int: debug(f"Transcript path does not exist: {transcript_path}") return 0 - langfuse = None - try: - langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host) - except Exception: - return 0 - try: with FileLock(LOCK_FILE): state = load_state() @@ -1158,7 +1190,7 @@ def main() -> int: turn_num = ss.turn_count + emitted try: emit_turn(langfuse, session_id, turn_num, t, transcript_path, - user_id=user_id, subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id) + user_id=config.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. From e071eca0d7aa08db4cff6c97c2ceb468aff38123 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 13:06:20 +0200 Subject: [PATCH 03/30] refactor(hook): remove unused final output metadata --- hooks/langfuse_hook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index de41ccc..fb53d85 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -900,13 +900,13 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, final_out_raw = tr_entry.get("final_content") if final_out_raw is not None: final_out_str = final_out_raw if isinstance(final_out_raw, str) else json.dumps(final_out_raw, ensure_ascii=False) - final_out_trunc, final_out_meta = truncate_text(final_out_str) + final_out_trunc, _ = truncate_text(final_out_str) final_tr_ts = parse_timestamp(tr_entry.get("final_timestamp")) else: - final_out_trunc, final_out_meta, final_tr_ts = None, None, None + final_out_trunc, final_tr_ts = None, None else: out_trunc, out_meta, tr_ts = None, None, None - final_out_trunc, final_out_meta, final_tr_ts = None, None, None + final_out_trunc, final_tr_ts = None, None tool_output_trunc = out_trunc tool_output_meta = out_meta From 259b6ae92cf6d2859e613efda03e3708da556629 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 13:26:23 +0200 Subject: [PATCH 04/30] refactor(hook): move payload validation out of main --- hooks/langfuse_hook.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index fb53d85..4dd5689 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -127,7 +127,7 @@ def info(msg: str) -> None: except Exception: pass -# ----------------- State locking (best-effort) ----------------- +# ----------------- State locking ----------------- class FileLock: def __init__(self, path: Path, timeout_s: float = 2.0): self.path = path @@ -259,6 +259,20 @@ def extract_session_id_and_transcript_path(payload: Dict[str, Any]) -> Tuple[Opt 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 + # ----------------- Transcript parsing helpers ----------------- def get_content_from_row(row: Dict[str, Any]) -> Any: if not isinstance(row, dict): @@ -1139,16 +1153,11 @@ def main() -> int: 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 try: with FileLock(LOCK_FILE): From ba07f5db5fc3ec748a76ad0d65f4d13d566e0446 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 13:46:09 +0200 Subject: [PATCH 05/30] refactor(hook): reorder sections to match main flow --- hooks/langfuse_hook.py | 143 +++++++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 71 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 4dd5689..db74189 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple + # ----------------- Langfuse import (fail-open) ----------------- try: from langfuse import Langfuse, propagate_attributes @@ -30,12 +31,14 @@ except Exception: sys.exit(0) + # ----------------- 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.""" @@ -49,7 +52,6 @@ def _opt(name: str) -> str: except ValueError: MAX_CHARS = 20000 - @dataclass class LangfuseConfig: public_key: str @@ -57,7 +59,6 @@ class LangfuseConfig: 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") @@ -74,7 +75,6 @@ def get_langfuse_config() -> Optional[LangfuseConfig]: user_id=user_id, ) - def create_langfuse_client(config: LangfuseConfig) -> Optional[Langfuse]: try: return Langfuse( @@ -127,6 +127,68 @@ def info(msg: str) -> None: except Exception: pass + +# ----------------- 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 + +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 + + # ----------------- State locking ----------------- class FileLock: def __init__(self, path: Path, timeout_s: float = 2.0): @@ -165,7 +227,7 @@ def __enter__(self): pass raise - def __exit__(self, exc_type, exc, tb): +def __exit__(self, exc_type, exc, tb): try: import fcntl fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) @@ -176,6 +238,8 @@ def __exit__(self, exc_type, exc, tb): except Exception: pass + +# ----------------- State management ----------------- def load_state() -> Dict[str, Any]: try: if not STATE_FILE.exists(): @@ -213,65 +277,6 @@ def state_key(session_id: str, transcript_path: str) -> str: raw = f"{session_id}::{transcript_path}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() -# ----------------- 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 - -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 # ----------------- Transcript parsing helpers ----------------- def get_content_from_row(row: Dict[str, Any]) -> Any: @@ -388,6 +393,7 @@ def parse_timestamp(value: Any) -> Optional[datetime]: except Exception: return None + # ----------------- Incremental reader ----------------- @dataclass class SessionState: @@ -466,6 +472,7 @@ def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[s return msgs, ss + # ----------------- Turn assembly ----------------- @dataclass class Turn: @@ -712,6 +719,7 @@ def flush_turn(): flush_turn() return turns + # ----------------- Langfuse emit ----------------- def _to_ns(ts: Optional[datetime]) -> Optional[int]: """Convert a datetime to OTel-style nanoseconds since epoch.""" @@ -719,7 +727,6 @@ def _to_ns(ts: Optional[datetime]) -> Optional[int]: return None return int(ts.timestamp() * 1_000_000_000) - def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, start_time: Optional[datetime], parent_otel_span: Any = None, @@ -757,7 +764,6 @@ def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, **obs_kwargs, ) - def collect_skill_tags(turn: Turn) -> List[str]: """Return 'skill:' tags for every Skill tool invocation in the turn.""" names: List[str] = [] @@ -771,7 +777,6 @@ def collect_skill_tags(turn: Turn) -> List[str]: 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() @@ -782,16 +787,13 @@ 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_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 emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, start_ts: Optional[datetime], generation_prefix: str = "Claude Generation", @@ -1011,7 +1013,6 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, return latest_end - def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, subagent: Dict[str, Any], start_ts: Optional[datetime]) -> Optional[datetime]: @@ -1073,7 +1074,6 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, return latest_end - 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: @@ -1139,6 +1139,7 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr trace_span.update(output={"role": "assistant", "content": final_assistant_text}) trace_span.end(end_time=_to_ns(_get_latest_timestamp(turn_end_ts, last_assistant_ts, obs_end_ts, user_ts))) + # ----------------- Main ----------------- def main() -> int: start = time.time() From 63c81ba4388429c777bedbbb741fb253fe503f17 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 14:21:13 +0200 Subject: [PATCH 06/30] refactor(hook): better naming --- hooks/langfuse_hook.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index db74189..431900b 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -227,7 +227,7 @@ def __enter__(self): pass raise -def __exit__(self, exc_type, exc, tb): + def __exit__(self, exc_type, exc, tb): try: import fcntl fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) @@ -240,7 +240,7 @@ def __exit__(self, exc_type, exc, tb): # ----------------- State management ----------------- -def load_state() -> Dict[str, Any]: +def load_hook_state() -> Dict[str, Any]: try: if not STATE_FILE.exists(): return {} @@ -248,7 +248,7 @@ def load_state() -> Dict[str, Any]: except Exception: return {} -def save_state(state: Dict[str, Any]) -> None: +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) @@ -270,9 +270,9 @@ 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: +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() @@ -402,7 +402,7 @@ class SessionState: turn_count: int = 0 # Turns already emitted for this session. pending_agent_turns: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) -def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState: +def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: s = global_state.get(key, {}) pending = s.get("pending_agent_turns") if not isinstance(pending, dict): @@ -414,7 +414,7 @@ def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState: pending_agent_turns=pending, ) -def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None: +def update_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None: global_state[key] = { "offset": ss.offset, "buffer": ss.buffer, @@ -423,6 +423,10 @@ def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState "updated": datetime.now(timezone.utc).isoformat(), } +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) + def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: """ Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line. @@ -1162,21 +1166,20 @@ def main() -> int: try: with FileLock(LOCK_FILE): - state = load_state() - key = state_key(session_id, str(transcript_path)) - ss = load_session_state(state, key) + state = load_hook_state() + key = get_session_state_key(session_id, str(transcript_path)) + ss = get_session_state(state, key) msgs, ss = read_new_jsonl(transcript_path, ss) if not msgs: - write_session_state(state, key, ss) - save_state(state) + save_session_state(state, key, ss) return 0 msgs = prepend_deferred_agent_turn_rows(msgs, ss) + turns = build_turns(msgs) if not turns: - write_session_state(state, key, ss) - save_state(state) + save_session_state(state, key, ss) return 0 subagent_transcripts_by_tool_use_id = get_subagent_transcripts_by_tool_use_id(transcript_path) @@ -1208,8 +1211,7 @@ def main() -> int: # continue emitting other turns ss.turn_count += emitted - write_session_state(state, key, ss) - save_state(state) + save_session_state(state, key, ss) dur = time.time() - start info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})") From 153f8837321c8e47d6add9f94f9670fb3f4239db Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 15:19:54 +0200 Subject: [PATCH 07/30] refactor(hook): clarify state helpers --- hooks/langfuse_hook.py | 80 +++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 431900b..bdfe356 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -189,7 +189,7 @@ def get_session_id_and_transcript_path(payload: Dict[str, Any]) -> Optional[Tupl return session_id, transcript_path -# ----------------- State locking ----------------- +# ----------------- State file concurrency control ----------------- class FileLock: def __init__(self, path: Path, timeout_s: float = 2.0): self.path = path @@ -239,7 +239,7 @@ def __exit__(self, exc_type, exc, tb): pass -# ----------------- State management ----------------- +# ----------------- State file reading and writing ----------------- def load_hook_state() -> Dict[str, Any]: try: if not STATE_FILE.exists(): @@ -248,6 +248,39 @@ def load_hook_state() -> Dict[str, Any]: except Exception: return {} +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: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + +def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: + s = global_state.get(key, {}) + pending = s.get("pending_agent_turns") + if not isinstance(pending, dict): + pending = {} + 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, + ) + +def update_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, + "pending_agent_turns": ss.pending_agent_turns 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. @@ -272,13 +305,12 @@ def save_hook_state(state: Dict[str, Any]) -> None: except Exception as e: debug(f"save_hook_state failed: {e}") -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() +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) -# ----------------- Transcript parsing helpers ----------------- +# ----------------- Transcript row parsing ----------------- def get_content_from_row(row: Dict[str, Any]) -> Any: if not isinstance(row, dict): return None @@ -394,39 +426,7 @@ def parse_timestamp(value: Any) -> Optional[datetime]: 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. - pending_agent_turns: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) - -def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: - s = global_state.get(key, {}) - pending = s.get("pending_agent_turns") - if not isinstance(pending, dict): - pending = {} - 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, - ) - -def update_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, - "pending_agent_turns": ss.pending_agent_turns or {}, - "updated": datetime.now(timezone.utc).isoformat(), - } - -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) - +# ----------------- Incremental transcript reading ----------------- def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: """ Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line. From 2dae246bf933ba49ac48c079b79e6fea901880be Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 15:28:55 +0200 Subject: [PATCH 08/30] refactor(hook): aligned naming across functions --- hooks/langfuse_hook.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index bdfe356..7c62d72 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -358,7 +358,7 @@ def get_tool_use_blocks(content: Any) -> List[Dict[str, Any]]: out.append(x) return out -def extract_text(content: Any) -> str: +def extract_text_from_content(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): @@ -380,8 +380,8 @@ def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, An 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_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" @@ -406,8 +406,8 @@ 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") +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: @@ -501,14 +501,14 @@ def _extract_xml_tag_value(text: str, tag: str) -> Optional[str]: return text[i + len(start):j] def get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str]: - notification_text = extract_text(get_content_from_row(row)).lstrip() + notification_text = extract_text_from_content(get_content_from_row(row)).lstrip() if not notification_text.startswith(""): return None 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_result_from_task_notification_row(row: Dict[str, Any]) -> str: - notification_text = extract_text(get_content_from_row(row)) + 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 @@ -650,7 +650,7 @@ def flush_turn(): # attach it to that tool span. src = row.get("sourceToolUseID") if src: - txt = extract_text(get_content_from_row(row)) + txt = extract_text_from_content(get_content_from_row(row)) if txt: injected_by_tool_id[str(src)] = txt current_rows.append(row) @@ -803,7 +803,7 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, generation_prefix: str = "Claude Generation", 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(get_content_from_row(turn.user_msg))) + user_text, _ = truncate_text(extract_text_from_content(get_content_from_row(turn.user_msg))) prev_ts = start_ts prev_tool_results: List[Dict[str, Any]] = [] pending_async_tool_results: List[Dict[str, Any]] = [] @@ -812,7 +812,7 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, 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_raw = extract_text_from_content(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)) @@ -1035,11 +1035,11 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, first_turn = turns[0] subagent_start_ts = parse_timestamp(first_turn.user_msg) or start_ts - subagent_input_text, subagent_input_meta = truncate_text(extract_text(get_content_from_row(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(get_content_from_row(last_assistant))) + 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" @@ -1081,11 +1081,11 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, 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(get_content_from_row(turn.user_msg)) + 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(get_content_from_row(last_assistant))) + 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) From c13131240e0d7c648d35df6546aeb7d901580275 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 15:33:11 +0200 Subject: [PATCH 09/30] refactor(hook): organize transcript parsing helpers --- hooks/langfuse_hook.py | 108 ++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 7c62d72..874afa6 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -333,52 +333,13 @@ 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_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 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(row: Dict[str, Any]) -> str: m = row.get("message") @@ -406,14 +367,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(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 parse_timestamp(value: Any) -> Optional[datetime]: """Parse a Claude Code jsonl row timestamp (ISO 8601 with trailing Z).""" if isinstance(value, dict): @@ -425,6 +378,53 @@ def parse_timestamp(value: Any) -> Optional[datetime]: except Exception: return None +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 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 + # ----------------- Incremental transcript reading ----------------- def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: From 4e5899662aa4b217f0116257193d1ba8393ac4d0 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 15:55:50 +0200 Subject: [PATCH 10/30] refactor(hook): simplify main emit flow --- hooks/langfuse_hook.py | 172 ++++++++++++++++++++++++++--------------- 1 file changed, 111 insertions(+), 61 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 874afa6..15b6801 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -724,6 +724,28 @@ def flush_turn(): return turns +def get_new_turns_from_transcript(transcript_path: Path, session_state: SessionState) -> Tuple[List[Turn], SessionState]: + rows, session_state = read_new_jsonl(transcript_path, session_state) + if not rows: + return [], session_state + + rows = prepend_deferred_agent_turn_rows(rows, session_state) + return build_turns(rows), session_state + + +def get_turns_to_emit(turns: List[Turn], session_state: SessionState) -> 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: + for tool_use_id in pending_agent_tool_use_ids: + session_state.pending_agent_turns[tool_use_id] = 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 + + # ----------------- Langfuse emit ----------------- def _to_ns(ts: Optional[datetime]) -> Optional[int]: """Convert a datetime to OTel-style nanoseconds since epoch.""" @@ -1144,6 +1166,93 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr trace_span.end(end_time=_to_ns(_get_latest_timestamp(turn_end_ts, last_assistant_ts, obs_end_ts, user_ts))) +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, + 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, +) -> 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) + + turns, session_state = get_new_turns_from_transcript(transcript_path, session_state) + if not turns: + save_session_state(state, key, session_state) + return 0 + + 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_to_emit = get_turns_to_emit(turns, session_state) + 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, + ) + + session_state.turn_count += emitted + save_session_state(state, key, session_state) + return emitted + + +def flush_and_shutdown_langfuse_client(langfuse: Optional[Langfuse]) -> None: + if langfuse is None: + return + + # 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() + + t = threading.Thread(target=_flush_and_shutdown, daemon=True) + t.start() + t.join(5.0) + except Exception: + pass + + # ----------------- Main ----------------- def main() -> int: start = time.time() @@ -1165,53 +1274,7 @@ def main() -> int: session_id, transcript_path = hook_context try: - with FileLock(LOCK_FILE): - state = load_hook_state() - key = get_session_state_key(session_id, str(transcript_path)) - ss = get_session_state(state, key) - - msgs, ss = read_new_jsonl(transcript_path, ss) - if not msgs: - save_session_state(state, key, ss) - return 0 - - msgs = prepend_deferred_agent_turn_rows(msgs, ss) - - turns = build_turns(msgs) - if not turns: - save_session_state(state, key, ss) - return 0 - - 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_to_emit: List[Turn] = [] - for t in turns: - pending_agent_tool_use_ids = get_pending_agent_tool_use_ids(t) - if pending_agent_tool_use_ids: - for tool_use_id in pending_agent_tool_use_ids: - ss.pending_agent_turns[tool_use_id] = t.rows - debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") - continue - turns_to_emit.append(t) - - # emit turns - emitted = 0 - for t in turns_to_emit: - emitted += 1 - turn_num = ss.turn_count + emitted - try: - emit_turn(langfuse, session_id, turn_num, t, transcript_path, - user_id=config.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}") - # continue emitting other turns - - ss.turn_count += emitted - save_session_state(state, key, ss) + emitted = emit_new_turns_from_transcript(langfuse, config, session_id, transcript_path) dur = time.time() - start info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})") @@ -1226,20 +1289,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()) From 2b635e94ddf2109dff1c0ea52335da77eb86aa96 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 16:12:14 +0200 Subject: [PATCH 11/30] refactor(hook): organize turn assembly helpers --- hooks/langfuse_hook.py | 94 +++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 15b6801..8679b9e 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -512,6 +512,17 @@ def get_result_from_task_notification_row(row: Dict[str, Any]) -> str: result = _extract_xml_tag_value(notification_text, "result") return result if result is not None else notification_text +def prepend_deferred_agent_turn_rows(rows: List[Dict[str, Any]], session_state: SessionState) -> List[Dict[str, Any]]: + if not session_state.pending_agent_turns: + return rows + rows_with_deferred_turns: List[Dict[str, Any]] = [] + for row in rows: + tool_use_id = get_tool_use_id_from_task_notification(row) + if tool_use_id and tool_use_id in session_state.pending_agent_turns: + rows_with_deferred_turns.extend(session_state.pending_agent_turns.pop(tool_use_id)) + rows_with_deferred_turns.append(row) + return rows_with_deferred_turns + def get_pending_agent_tool_use_ids(turn: Turn) -> List[str]: tool_use_ids: List[str] = [] for assistant_message in turn.assistant_msgs: @@ -530,44 +541,17 @@ def get_pending_agent_tool_use_ids(turn: Turn) -> List[str]: tool_use_ids.append(tool_use_id) return tool_use_ids -def prepend_deferred_agent_turn_rows(messages: List[Dict[str, Any]], ss: SessionState) -> List[Dict[str, Any]]: - if not ss.pending_agent_turns: - return messages - rows: List[Dict[str, Any]] = [] - for row in messages: - tool_use_id = get_tool_use_id_from_task_notification(row) - if tool_use_id and tool_use_id in ss.pending_agent_turns: - rows.extend(ss.pending_agent_turns.pop(tool_use_id)) - rows.append(row) - return rows - -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(): +def get_turns_to_emit(turns: List[Turn], session_state: SessionState) -> 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: + for tool_use_id in pending_agent_tool_use_ids: + session_state.pending_agent_turns[tool_use_id] = turn.rows + debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") continue - - subagent_transcripts_by_tool_use_id[tool_use_id] = { - "path": jsonl_path, - "agent_type": metadata.get("agentType"), - "description": metadata.get("description"), - } - return subagent_transcripts_by_tool_use_id + turns_to_emit.append(turn) + return turns_to_emit def merge_assistant_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: """ @@ -733,17 +717,33 @@ def get_new_turns_from_transcript(transcript_path: Path, session_state: SessionS return build_turns(rows), session_state -def get_turns_to_emit(turns: List[Turn], session_state: SessionState) -> 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: - for tool_use_id in pending_agent_tool_use_ids: - session_state.pending_agent_turns[tool_use_id] = turn.rows - debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") +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 - turns_to_emit.append(turn) - return turns_to_emit + + 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 + + subagent_transcripts_by_tool_use_id[tool_use_id] = { + "path": jsonl_path, + "agent_type": metadata.get("agentType"), + "description": metadata.get("description"), + } + return subagent_transcripts_by_tool_use_id # ----------------- Langfuse emit ----------------- From 507f65a508de455b718345552a28006b72233f65 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 16:31:00 +0200 Subject: [PATCH 12/30] refactor(hook): clarify emit observation hierarchy --- hooks/langfuse_hook.py | 293 +++++++++++++++++++++++------------------ 1 file changed, 168 insertions(+), 125 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 8679b9e..76cd2ac 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -820,10 +820,159 @@ 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 emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, - start_ts: Optional[datetime], - generation_prefix: str = "Claude Generation", - subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None) -> Optional[datetime]: +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} + if previous_tool_results: + return {"role": "tool", "tool_results": previous_tool_results} + if ready_async_tool_results: + return { + "role": "tool", + "tool_results": [result["tool_result"] for result in ready_async_tool_results], + } + return None + +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 + +def emit_tool_observations( + 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]], +) -> Tuple[List[datetime], List[Dict[str, Any]], Optional[datetime]]: + assistant_timestamp = parse_timestamp(assistant_message) + batch_result_timestamps: List[datetime] = [] + batch_tool_results: List[Dict[str, Any]] = [] + latest_tool_end: Optional[datetime] = None + + for tool_use in tool_uses: + tool_use_id = str(tool_use.get("id") or "") + tool_name = tool_use.get("name") or "unknown" + 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): + tool_input, tool_input_meta = truncate_text(tool_input_raw) + else: + tool_input, tool_input_meta = tool_input_raw, None + + tool_result_entry = turn.tool_results_by_id.get(tool_use_id) if tool_use_id else None + if tool_result_entry: + 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_trunc, output_meta = truncate_text(output_str) + tool_result_timestamp = parse_timestamp(tool_result_entry.get("timestamp")) + final_output_raw = tool_result_entry.get("final_content") + if final_output_raw is not None: + final_output_str = final_output_raw if isinstance(final_output_raw, str) else json.dumps(final_output_raw, ensure_ascii=False) + final_output_trunc, _ = truncate_text(final_output_str) + final_tool_result_timestamp = parse_timestamp(tool_result_entry.get("final_timestamp")) + else: + final_output_trunc, final_tool_result_timestamp = None, None + else: + output_trunc, output_meta, tool_result_timestamp = None, None, None + final_output_trunc, final_tool_result_timestamp = None, None + + tool_output: Any = output_trunc + 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": output_trunc, "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: Dict[str, Any] = { + "tool_name": tool_name, + "tool_id": tool_use_id, + "input_meta": tool_input_meta, + "output_meta": output_meta, + } + if subagent: + tool_metadata.update({ + "subagent_type": subagent.get("agent_type"), + "subagent_description": subagent.get("description"), + "subagent_transcript_path": str(subagent.get("path")), + }) + + 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 final_tool_result_timestamp is not None: + pending_subagents.append({ + "subagent": subagent, + "start_timestamp": tool_use_timestamp, + "ready_timestamp": final_tool_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_timestamp, tool_use_timestamp) + handoff_timestamp = tool_result_timestamp or final_tool_result_timestamp or subagent_end_timestamp or assistant_timestamp + if handoff_timestamp is not None: + batch_result_timestamps.append(handoff_timestamp) + tool_span.end(end_time=_to_ns(tool_end_timestamp)) + latest_tool_end = _get_latest_timestamp(latest_tool_end, tool_end_timestamp, subagent_end_timestamp) + + batch_tool_results.append({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output_trunc, + }) + if final_tool_result_timestamp is not None and final_output_trunc is not None: + pending_async_tool_results.append({ + "timestamp": final_tool_result_timestamp, + "tool_result": { + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": final_output_trunc, + }, + }) + + return batch_result_timestamps, batch_tool_results, latest_tool_end + +def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, + start_ts: Optional[datetime], + generation_prefix: str = "Claude Generation", + 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))) prev_ts = start_ts @@ -874,30 +1023,8 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, ]) prev_ts = _get_latest_timestamp(prev_ts, ready_ts) - if idx == 0: - gen_input: Any = {"role": "user", "content": user_text} - elif prev_tool_results: - gen_input = {"role": "tool", "tool_results": prev_tool_results} - elif ready_async_tool_results: - gen_input = { - "role": "tool", - "tool_results": [result["tool_result"] for result in ready_async_tool_results], - } - else: - gen_input = None - - gen_tool_calls = [] - for tu in tool_uses: - gen_tool_calls.append({ - "id": tu.get("id"), - "name": tu.get("name"), - }) - - 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_input = build_generation_input(idx, user_text, prev_tool_results, ready_async_tool_results) + gen_output = build_generation_output(am_text, tool_uses) gen_kwargs: Dict[str, Any] = dict( model=model, @@ -922,101 +1049,17 @@ def emit_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, **gen_kwargs, ) - 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")) - final_out_raw = tr_entry.get("final_content") - if final_out_raw is not None: - final_out_str = final_out_raw if isinstance(final_out_raw, str) else json.dumps(final_out_raw, ensure_ascii=False) - final_out_trunc, _ = truncate_text(final_out_str) - final_tr_ts = parse_timestamp(tr_entry.get("final_timestamp")) - else: - final_out_trunc, final_tr_ts = None, None - else: - out_trunc, out_meta, tr_ts = None, None, None - final_out_trunc, final_tr_ts = None, None - - tool_output_trunc = out_trunc - tool_output_meta = out_meta - tool_output: Any = tool_output_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": tool_output_trunc, "injected_instructions": injected_trunc} - - sub = subagent_transcripts_by_tool_use_id.get(tid) if subagent_transcripts_by_tool_use_id and tid else None - tool_meta: Dict[str, Any] = { - "tool_name": tname, - "tool_id": tid, - "input_meta": tinput_meta, - "output_meta": tool_output_meta, - } - if sub: - tool_meta.update({ - "subagent_type": sub.get("agent_type"), - "subagent_description": sub.get("description"), - "subagent_transcript_path": str(sub.get("path")), - }) - - tool_use_ts = parse_timestamp(turn.tool_use_timestamps_by_id.get(tid)) or am_ts - tool_span = _start_backdated( - langfuse, - name=f"Tool: {tname}", - as_type="tool", - start_time=tool_use_ts, - parent_otel_span=parent_otel_span, - input=tinput, - metadata=tool_meta, - ) - tool_span.update(output=tool_output) - - subagent_end_ts = None - if sub: - if final_tr_ts is not None: - pending_subagents.append({ - "subagent": sub, - "start_timestamp": tool_use_ts, - "ready_timestamp": final_tr_ts, - }) - else: - subagent_end_ts = emit_subagent_observations(langfuse, parent_otel_span, sub, tool_use_ts) - - tool_end_ts = _get_latest_timestamp(tr_ts, tool_use_ts) - handoff_ts = tr_ts or final_tr_ts or subagent_end_ts or am_ts - if handoff_ts is not None: - batch_result_ts.append(handoff_ts) - tool_span.end(end_time=_to_ns(tool_end_ts)) - latest_end = _get_latest_timestamp(latest_end, tool_end_ts, subagent_end_ts) - - batch_tool_results.append({ - "tool_use_id": tid, - "tool_name": tname, - "output": out_trunc, - }) - if final_tr_ts is not None and final_out_trunc is not None: - pending_async_tool_results.append({ - "timestamp": final_tr_ts, - "tool_result": { - "tool_use_id": tid, - "tool_name": tname, - "output": final_out_trunc, - }, - }) + batch_result_ts, batch_tool_results, latest_tool_end = emit_tool_observations( + langfuse, + parent_otel_span, + turn, + am, + tool_uses, + subagent_transcripts_by_tool_use_id, + pending_subagents, + pending_async_tool_results, + ) + latest_end = _get_latest_timestamp(latest_end, latest_tool_end) 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)) @@ -1083,7 +1126,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, latest_end = subagent_start_ts prev_start = subagent_start_ts for turn in turns: - latest = emit_observations( + latest = emit_turn_observations( langfuse, subagent_span._otel_span, turn, @@ -1155,7 +1198,7 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr input={"role": "user", "content": user_text}, metadata=trace_metadata, ) - obs_end_ts = emit_observations( + obs_end_ts = emit_turn_observations( langfuse, trace_span._otel_span, turn, From 1d63cad21327eb1cea99ef10e78dea62a69034aa Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 16:47:00 +0200 Subject: [PATCH 13/30] refactor(hook): aligned method names --- hooks/langfuse_hook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 76cd2ac..2dbd3a1 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -507,7 +507,7 @@ def get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str] 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_result_from_task_notification_row(row: Dict[str, Any]) -> str: +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 @@ -657,11 +657,11 @@ def flush_turn(): if current_turn_user_row is not None: existing_result = tool_results_by_id.get(task_tid) if isinstance(existing_result, dict): - existing_result["final_content"] = get_result_from_task_notification_row(row) + existing_result["final_content"] = get_result_from_task_notification(row) existing_result["final_timestamp"] = row.get("timestamp") else: tool_results_by_id[task_tid] = { - "content": get_result_from_task_notification_row(row), + "content": get_result_from_task_notification(row), "timestamp": row.get("timestamp"), } current_rows.append(row) From 302c9e0fbb6f298f87ad2381f741248d23ae733d Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 23:02:01 +0200 Subject: [PATCH 14/30] refactor(hook): added Classe sand script substructure --- hooks/langfuse_hook.py | 646 +++++++++++++++++++++++++---------------- 1 file changed, 399 insertions(+), 247 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 2dbd3a1..6950481 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -489,6 +489,17 @@ class Turn: 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"" @@ -553,6 +564,62 @@ def get_turns_to_emit(turns: List[Turn], session_state: SessionState) -> List[Tu 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") + 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: + state.tool_results_by_id[str(tool_use_id)] = { + "content": tool_result_block.get("content"), + "timestamp": row_timestamp, + } + return True + +def add_task_notification_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: + tool_use_id = get_tool_use_id_from_task_notification(row) + if not tool_use_id: + return False + + if state.current_turn_user_row is None: + state.current_turn_user_row = row + state.current_rows = [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]: """ Claude Code can split one assistant message across multiple JSONL rows that @@ -579,8 +646,61 @@ 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) -def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]: + 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(rows: List[Dict[str, Any]]) -> List[Turn]: """ Groups incremental transcript rows into turns: user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved) @@ -589,122 +709,37 @@ 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 - tool_use_timestamps_by_id: Dict[str, Any] = {} # tool_use_id -> row timestamp - injected_by_tool_id: Dict[str, str] = {} # tool_use_id -> injected text (skill instructions) - current_rows: List[Dict[str, Any]] = [] - - def flush_turn(): - nonlocal current_turn_user_row, assistant_message_ids, assistant_rows_by_message_id, tool_results_by_id, tool_use_timestamps_by_id, injected_by_tool_id, current_rows, 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), - tool_use_timestamps_by_id=dict(tool_use_timestamps_by_id), - injected_by_tool_id=dict(injected_by_tool_id), - rows=list(current_rows), - )) - - 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_from_content(get_content_from_row(row)) - if txt: - injected_by_tool_id[str(src)] = txt - current_rows.append(row) + for row in rows: + if add_injected_context_row(row, state): continue - role = get_user_or_assistant_role_from_row(row) - - # tool_result rows show up as role=user with content blocks of type tool_result - if is_tool_result(row): - current_rows.append(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_tool_result_row(row, state): continue - task_tid = get_tool_use_id_from_task_notification(row) - if task_tid: - if current_turn_user_row is not None: - existing_result = tool_results_by_id.get(task_tid) - if isinstance(existing_result, dict): - existing_result["final_content"] = get_result_from_task_notification(row) - existing_result["final_timestamp"] = row.get("timestamp") - else: - tool_results_by_id[task_tid] = { - "content": get_result_from_task_notification(row), - "timestamp": row.get("timestamp"), - } - current_rows.append(row) - else: - current_turn_user_row = row - current_rows = [row] + if add_task_notification_row(row, state): 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 = {} - tool_use_timestamps_by_id = {} - injected_by_tool_id = {} - current_rows = [row] + 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) - 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: - tool_use_timestamps_by_id.setdefault(str(tool_use_id), row.get("timestamp")) - current_rows.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 @@ -716,7 +751,6 @@ def get_new_turns_from_transcript(transcript_path: Path, session_state: SessionS rows = prepend_deferred_agent_turn_rows(rows, session_state) return build_turns(rows), 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" @@ -747,12 +781,18 @@ def get_subagent_transcripts_by_tool_use_id(transcript_path: Path) -> Dict[str, # ----------------- 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], parent_otel_span: Any = None, @@ -778,7 +818,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) @@ -790,6 +830,7 @@ 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] = [] @@ -816,10 +857,13 @@ def short_session_label(session_id: str, max_len: int = 12) -> str: 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_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 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, @@ -851,7 +895,179 @@ def build_generation_output(assistant_text: str, tool_uses: List[Dict[str, Any]] ] return output -def emit_tool_observations( +# ---- 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 + +@dataclass +class EmittedSingleToolObservation: + handoff_timestamp: Optional[datetime] + tool_result: Dict[str, Any] + latest_end_timestamp: Optional[datetime] + +@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 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": str(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({ + "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, @@ -860,115 +1076,38 @@ def emit_tool_observations( 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]], -) -> Tuple[List[datetime], List[Dict[str, Any]], Optional[datetime]]: +) -> EmittedToolObservationBatch: assistant_timestamp = parse_timestamp(assistant_message) - batch_result_timestamps: List[datetime] = [] - batch_tool_results: List[Dict[str, Any]] = [] - latest_tool_end: Optional[datetime] = None + 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: - tool_use_id = str(tool_use.get("id") or "") - tool_name = tool_use.get("name") or "unknown" - 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): - tool_input, tool_input_meta = truncate_text(tool_input_raw) - else: - tool_input, tool_input_meta = tool_input_raw, None - - tool_result_entry = turn.tool_results_by_id.get(tool_use_id) if tool_use_id else None - if tool_result_entry: - 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_trunc, output_meta = truncate_text(output_str) - tool_result_timestamp = parse_timestamp(tool_result_entry.get("timestamp")) - final_output_raw = tool_result_entry.get("final_content") - if final_output_raw is not None: - final_output_str = final_output_raw if isinstance(final_output_raw, str) else json.dumps(final_output_raw, ensure_ascii=False) - final_output_trunc, _ = truncate_text(final_output_str) - final_tool_result_timestamp = parse_timestamp(tool_result_entry.get("final_timestamp")) - else: - final_output_trunc, final_tool_result_timestamp = None, None - else: - output_trunc, output_meta, tool_result_timestamp = None, None, None - final_output_trunc, final_tool_result_timestamp = None, None - - tool_output: Any = output_trunc - 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": output_trunc, "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: Dict[str, Any] = { - "tool_name": tool_name, - "tool_id": tool_use_id, - "input_meta": tool_input_meta, - "output_meta": output_meta, - } - if subagent: - tool_metadata.update({ - "subagent_type": subagent.get("agent_type"), - "subagent_description": subagent.get("description"), - "subagent_transcript_path": str(subagent.get("path")), - }) - - tool_use_timestamp = parse_timestamp(turn.tool_use_timestamps_by_id.get(tool_use_id)) or assistant_timestamp - tool_span = _start_backdated( + emitted_tool = emit_single_tool_observation( 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, + 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, ) - tool_span.update(output=tool_output) - - subagent_end_timestamp = None - if subagent: - if final_tool_result_timestamp is not None: - pending_subagents.append({ - "subagent": subagent, - "start_timestamp": tool_use_timestamp, - "ready_timestamp": final_tool_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_timestamp, tool_use_timestamp) - handoff_timestamp = tool_result_timestamp or final_tool_result_timestamp or subagent_end_timestamp or assistant_timestamp - if handoff_timestamp is not None: - batch_result_timestamps.append(handoff_timestamp) - tool_span.end(end_time=_to_ns(tool_end_timestamp)) - latest_tool_end = _get_latest_timestamp(latest_tool_end, tool_end_timestamp, subagent_end_timestamp) - - batch_tool_results.append({ - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": output_trunc, - }) - if final_tool_result_timestamp is not None and final_output_trunc is not None: - pending_async_tool_results.append({ - "timestamp": final_tool_result_timestamp, - "tool_result": { - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": final_output_trunc, - }, - }) - return batch_result_timestamps, batch_tool_results, latest_tool_end + return EmittedToolObservationBatch( + result_timestamps=tool_result_timestamps, + tool_results=emitted_tool_results, + latest_end_timestamp=latest_tool_end_timestamp, + ) +# ---- Turn and subagent observations ---- def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, start_ts: Optional[datetime], generation_prefix: str = "Claude Generation", @@ -1049,7 +1188,7 @@ def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn **gen_kwargs, ) - batch_result_ts, batch_tool_results, latest_tool_end = emit_tool_observations( + emitted_tools = emit_tool_observation_batch( langfuse, parent_otel_span, turn, @@ -1059,15 +1198,15 @@ def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn pending_subagents, pending_async_tool_results, ) - latest_end = _get_latest_timestamp(latest_end, latest_tool_end) + latest_end = _get_latest_timestamp(latest_end, emitted_tools.latest_end_timestamp) - 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)) + gen_end_ts = max(emitted_tools.result_timestamps) if emitted_tools.result_timestamps else am_ts + gen_span.end(end_time=to_otel_nanoseconds(gen_end_ts or am_ts or prev_ts)) latest_end = _get_latest_timestamp(latest_end, gen_end_ts) - prev_tool_results = batch_tool_results - if batch_result_ts: - prev_ts = max(batch_result_ts) + prev_tool_results = emitted_tools.tool_results + if emitted_tools.result_timestamps: + prev_ts = max(emitted_tools.result_timestamps) elif am_ts is not None: prev_ts = am_ts @@ -1139,10 +1278,46 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, prev_start = latest subagent_span.update(output={"role": "assistant", "content": subagent_output_text}) - subagent_span.end(end_time=_to_ns(_get_latest_timestamp(latest_end, subagent_start_ts))) + subagent_span.end(end_time=to_otel_nanoseconds(_get_latest_timestamp(latest_end, subagent_start_ts))) return latest_end +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), + "user_text": user_text_meta, + "assistant_message_count": len(turn.assistant_msgs), + } + # 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")): + value = turn.user_msg.get(src_key) + if isinstance(value, str) and value: + trace_metadata[dst_key] = value + return trace_metadata + 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: @@ -1154,32 +1329,9 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr 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 - - trace_metadata: Dict[str, Any] = { - "source": "claude-code", - "session_id": session_id, - "turn_number": turn_num, - "transcript_path": str(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. - 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 - - tags = ["claude-code"] - if SKILL_TAGS: - tags += collect_skill_tags(turn) + 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}" @@ -1206,9 +1358,10 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr 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_ns(_get_latest_timestamp(turn_end_ts, last_assistant_ts, obs_end_ts, user_ts))) + 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, @@ -1239,7 +1392,6 @@ def emit_ready_turns( info(f"emit_turn failed: {type(e).__name__}: {e}") return emitted - def emit_new_turns_from_transcript( langfuse: Langfuse, config: LangfuseConfig, From 6c072f0143bf417a30f9cac34492d21857727f35 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Wed, 1 Jul 2026 23:28:30 +0200 Subject: [PATCH 15/30] refactor(hook): split turn observation emission --- hooks/langfuse_hook.py | 300 +++++++++++++++++++++++++---------------- 1 file changed, 187 insertions(+), 113 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 6950481..fb324b8 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -142,7 +142,9 @@ def read_hook_payload() -> Dict[str, Any]: parsed = json.loads(data) if isinstance(parsed, dict): debug(f"payload top-level keys: {sorted(parsed.keys())}") - return parsed + 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 {} @@ -272,12 +274,12 @@ def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: pending_agent_turns=pending, ) -def update_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None: +def update_session_state(global_state: Dict[str, Any], key: str, session_state: SessionState) -> None: global_state[key] = { - "offset": ss.offset, - "buffer": ss.buffer, - "turn_count": ss.turn_count, - "pending_agent_turns": ss.pending_agent_turns or {}, + "offset": session_state.offset, + "buffer": session_state.buffer, + "turn_count": session_state.turn_count, + "pending_agent_turns": session_state.pending_agent_turns or {}, "updated": datetime.now(timezone.utc).isoformat(), } @@ -427,42 +429,42 @@ def is_tool_result(row: Dict[str, Any]) -> bool: # ----------------- Incremental transcript reading ----------------- -def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]: +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]: @@ -474,7 +476,7 @@ 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 ----------------- @@ -834,12 +836,12 @@ def _start_backdated(langfuse: Langfuse, *, name: str, as_type: str, 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 @@ -1108,137 +1110,205 @@ def emit_tool_observation_batch( ) # ---- 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 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_ts: Optional[datetime], + start_timestamp: Optional[datetime], generation_prefix: str = "Claude Generation", 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))) - prev_ts = start_ts - prev_tool_results: List[Dict[str, Any]] = [] + 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 = start_ts - - for idx, am in enumerate(turn.assistant_msgs): - am_ts = parse_timestamp(am) - am_text_raw = extract_text_from_content(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)) - ready_subagents: List[Dict[str, Any]] = [] - if idx > 0 and pending_subagents: - still_pending_subagents: List[Dict[str, Any]] = [] - for pending_subagent in pending_subagents: - ready_ts = pending_subagent.get("ready_timestamp") - if isinstance(ready_ts, datetime) and (am_ts is None or ready_ts <= am_ts): - ready_subagents.append(pending_subagent) - else: - still_pending_subagents.append(pending_subagent) - pending_subagents = still_pending_subagents + 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_ts = emit_subagent_observations( + subagent_end_timestamp = emit_subagent_observations( langfuse, parent_otel_span, ready_subagent["subagent"], ready_subagent.get("start_timestamp"), ) - latest_end = _get_latest_timestamp(latest_end, subagent_end_ts) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) ready_async_tool_results: List[Dict[str, Any]] = [] - if idx > 0 and pending_async_tool_results: - still_pending: List[Dict[str, Any]] = [] - for async_result in pending_async_tool_results: - async_ts = async_result.get("timestamp") - if isinstance(async_ts, datetime) and (am_ts is None or async_ts <= am_ts): - ready_async_tool_results.append(async_result) - else: - still_pending.append(async_result) - pending_async_tool_results = still_pending - if ready_async_tool_results: - ready_ts = _get_latest_timestamp(*[ - result.get("timestamp") for result in ready_async_tool_results - if isinstance(result.get("timestamp"), datetime) - ]) - prev_ts = _get_latest_timestamp(prev_ts, ready_ts) - - gen_input = build_generation_input(idx, user_text, prev_tool_results, ready_async_tool_results) - gen_output = build_generation_output(am_text, tool_uses) - - 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), - }, + 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, ) - usage_details = get_usage_details_from_row(am) - if usage_details is not None: - gen_kwargs["usage_details"] = usage_details - - gen_span = _start_backdated( + generation_span = emit_generation_observation( langfuse, - name=f"{generation_prefix} {idx + 1}", - as_type="generation", - start_time=prev_ts or am_ts, parent_otel_span=parent_otel_span, - **gen_kwargs, + generation_prefix=generation_prefix, + assistant_index=assistant_index, + start_timestamp=previous_timestamp or assistant_timestamp, + generation_kwargs=generation_kwargs, ) emitted_tools = emit_tool_observation_batch( langfuse, parent_otel_span, turn, - am, + assistant_message, tool_uses, subagent_transcripts_by_tool_use_id, pending_subagents, pending_async_tool_results, ) - latest_end = _get_latest_timestamp(latest_end, emitted_tools.latest_end_timestamp) + latest_end_timestamp = _get_latest_timestamp( + latest_end_timestamp, + emitted_tools.latest_end_timestamp, + ) - gen_end_ts = max(emitted_tools.result_timestamps) if emitted_tools.result_timestamps else am_ts - gen_span.end(end_time=to_otel_nanoseconds(gen_end_ts or am_ts or prev_ts)) - latest_end = _get_latest_timestamp(latest_end, gen_end_ts) + 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) - prev_tool_results = emitted_tools.tool_results + previous_tool_results = emitted_tools.tool_results if emitted_tools.result_timestamps: - prev_ts = max(emitted_tools.result_timestamps) - elif am_ts is not None: - prev_ts = am_ts + 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_ts = emit_subagent_observations( + subagent_end_timestamp = emit_subagent_observations( langfuse, parent_otel_span, pending_subagent["subagent"], pending_subagent.get("start_timestamp"), ) - latest_end = _get_latest_timestamp(latest_end, subagent_end_ts) + latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) - return latest_end + return latest_end_timestamp def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, subagent: Dict[str, Any], - start_ts: Optional[datetime]) -> Optional[datetime]: + start_timestamp: Optional[datetime]) -> Optional[datetime]: path = subagent.get("path") if not isinstance(path, Path): - return start_ts + return start_timestamp try: rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] except Exception as e: info(f"subagent transcript read failed ({path}): {type(e).__name__}: {e}") - return start_ts + return start_timestamp turns = build_turns(rows) if not turns: - return start_ts + return start_timestamp first_turn = turns[0] - subagent_start_ts = parse_timestamp(first_turn.user_msg) or start_ts + subagent_start_timestamp = parse_timestamp(first_turn.user_msg) or start_timestamp subagent_input_text, subagent_input_meta = truncate_text(extract_text_from_content(get_content_from_row(first_turn.user_msg))) last_turn = turns[-1] @@ -1251,7 +1321,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, langfuse, name=subagent_name, as_type="span", - start_time=subagent_start_ts, + start_time=subagent_start_timestamp, parent_otel_span=parent_otel_span, input={"role": "user", "content": subagent_input_text}, metadata={ @@ -1262,25 +1332,29 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, }, ) - latest_end = subagent_start_ts - prev_start = subagent_start_ts + latest_end_timestamp = subagent_start_timestamp + previous_start_timestamp = subagent_start_timestamp for turn in turns: - latest = emit_turn_observations( + latest_turn_timestamp = emit_turn_observations( langfuse, subagent_span._otel_span, turn, - prev_start, + previous_start_timestamp, generation_prefix="Subagent Generation", subagent_transcripts_by_tool_use_id=None, ) - latest_end = _get_latest_timestamp(latest_end, latest) - if latest is not None: - prev_start = latest + 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, subagent_start_ts))) + subagent_span.end( + end_time=to_otel_nanoseconds( + _get_latest_timestamp(latest_end_timestamp, subagent_start_timestamp) + ) + ) - return latest_end + return latest_end_timestamp def get_turn_end_timestamp(turn: Turn) -> Optional[datetime]: last_assistant_timestamp = parse_timestamp(turn.assistant_msgs[-1]) if turn.assistant_msgs else None From 53e2332213863c5dc736df4cad94eeaeee1f185e Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 10:03:44 +0200 Subject: [PATCH 16/30] adjusted naming --- hooks/langfuse_hook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index fb324b8..d01803b 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -1198,7 +1198,7 @@ def emit_generation_observation( def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn, start_timestamp: Optional[datetime], - generation_prefix: str = "Claude Generation", + 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))) @@ -1340,7 +1340,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, subagent_span._otel_span, turn, previous_start_timestamp, - generation_prefix="Subagent Generation", + generation_prefix="Subagent LLM Call", subagent_transcripts_by_tool_use_id=None, ) latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, latest_turn_timestamp) @@ -1408,7 +1408,7 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, turn: Turn, tr 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, From 8e6536002468d3fa9531a557a11ee643eea67684 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 11:09:45 +0200 Subject: [PATCH 17/30] fix(hook): order async subagent spans after launch response --- hooks/langfuse_hook.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index d01803b..fb60318 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -1028,6 +1028,7 @@ def emit_single_tool_observation( 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, @@ -1148,6 +1149,30 @@ def get_ready_async_tool_results( ]) 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], @@ -1220,7 +1245,7 @@ def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn langfuse, parent_otel_span, ready_subagent["subagent"], - ready_subagent.get("start_timestamp"), + ready_subagent.get("display_start_timestamp") or ready_subagent.get("start_timestamp"), ) latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) @@ -1238,14 +1263,20 @@ def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn 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=previous_timestamp or assistant_timestamp, + 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, @@ -1285,7 +1316,7 @@ def emit_turn_observations(langfuse: Langfuse, parent_otel_span: Any, turn: Turn langfuse, parent_otel_span, pending_subagent["subagent"], - pending_subagent.get("start_timestamp"), + pending_subagent.get("display_start_timestamp") or pending_subagent.get("start_timestamp"), ) latest_end_timestamp = _get_latest_timestamp(latest_end_timestamp, subagent_end_timestamp) @@ -1308,7 +1339,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, return start_timestamp first_turn = turns[0] - subagent_start_timestamp = parse_timestamp(first_turn.user_msg) or start_timestamp + 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] From f93d942cad6714d8448c82f1981352958681ab26 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 11:23:50 +0200 Subject: [PATCH 18/30] fix(hook): avoid full transcript paths in metadata --- hooks/langfuse_hook.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index fb60318..20a7a2f 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -960,6 +960,13 @@ def get_tool_result_for_observation(tool_result_entry: Any) -> ToolResultForObse 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, @@ -977,7 +984,7 @@ def build_tool_metadata( tool_metadata.update({ "subagent_type": subagent.get("agent_type"), "subagent_description": subagent.get("description"), - "subagent_transcript_path": str(subagent.get("path")), + "subagent_transcript_path": get_short_transcript_path_for_metadata(subagent.get("path")), }) return tool_metadata @@ -1358,7 +1365,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, metadata={ "agent_type": subagent.get("agent_type"), "description": description, - "transcript_path": str(path), + "transcript_path": get_short_transcript_path_for_metadata(path), "user_text": subagent_input_meta, }, ) @@ -1411,7 +1418,7 @@ def build_trace_metadata( "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), } From 662007cb86cea3d15c68698d7bb8281a9e5a43ac Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 12:39:53 +0200 Subject: [PATCH 19/30] fix(hook): avoid tracing task notifications as turns --- hooks/langfuse_hook.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 20a7a2f..916518a 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -513,10 +513,16 @@ def _extract_xml_tag_value(text: str, tag: str) -> Optional[str]: return None return text[i + len(start):j] -def get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str]: +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() - if not notification_text.startswith(""): - return None + 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 @@ -601,13 +607,15 @@ def add_tool_result_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: return True def add_task_notification_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: - tool_use_id = get_tool_use_id_from_task_notification(row) - if not tool_use_id: + if not is_task_notification_row(row): return False if state.current_turn_user_row is None: - state.current_turn_user_row = row - state.current_rows = [row] + return True + + tool_use_id = get_tool_use_id_from_task_notification(row) + if not tool_use_id: + state.current_rows.append(row) return True existing_result = state.tool_results_by_id.get(tool_use_id) From e32b07d29ae8694d95d50f80ef4532572c8308c8 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 13:04:32 +0200 Subject: [PATCH 20/30] fix(hook): map task notifications to agent tool calls --- hooks/langfuse_hook.py | 84 +++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 916518a..7f96868 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -526,17 +526,39 @@ def get_tool_use_id_from_task_notification(row: Dict[str, Any]) -> Optional[str] 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]: + 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 prepend_deferred_agent_turn_rows(rows: List[Dict[str, Any]], session_state: SessionState) -> List[Dict[str, Any]]: +def prepend_deferred_agent_turn_rows( + rows: List[Dict[str, Any]], + session_state: SessionState, + task_id_to_tool_use_id: Optional[Dict[str, str]] = None, +) -> List[Dict[str, Any]]: if not session_state.pending_agent_turns: return rows rows_with_deferred_turns: List[Dict[str, Any]] = [] for row in rows: - tool_use_id = get_tool_use_id_from_task_notification(row) + tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) if tool_use_id and tool_use_id in session_state.pending_agent_turns: rows_with_deferred_turns.extend(session_state.pending_agent_turns.pop(tool_use_id)) rows_with_deferred_turns.append(row) @@ -606,14 +628,18 @@ def add_tool_result_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: } return True -def add_task_notification_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: +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_from_task_notification(row) + 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 @@ -710,7 +736,10 @@ def add_assistant_row(row: Dict[str, Any], state: TurnAssemblyState) -> None: state.current_rows.append(row) -def build_turns(rows: 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) @@ -728,7 +757,7 @@ def build_turns(rows: List[Dict[str, Any]]) -> List[Turn]: if add_tool_result_row(row, state): continue - if add_task_notification_row(row, state): + if add_task_notification_row(row, state, task_id_to_tool_use_id): continue role = get_user_or_assistant_role_from_row(row) @@ -753,13 +782,18 @@ def build_turns(rows: List[Dict[str, Any]]) -> List[Turn]: return turns -def get_new_turns_from_transcript(transcript_path: Path, session_state: SessionState) -> Tuple[List[Turn], SessionState]: +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, +) -> Tuple[List[Turn], SessionState]: rows, session_state = read_new_jsonl(transcript_path, session_state) if not rows: return [], session_state - rows = prepend_deferred_agent_turn_rows(rows, session_state) - return build_turns(rows), session_state + task_id_to_tool_use_id = get_task_id_to_tool_use_id(subagent_transcripts_by_tool_use_id) + rows = prepend_deferred_agent_turn_rows(rows, session_state, task_id_to_tool_use_id) + return build_turns(rows, task_id_to_tool_use_id), 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.""" @@ -782,13 +816,31 @@ def get_subagent_transcripts_by_tool_use_id(transcript_path: Path) -> Dict[str, 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 ----------------- @@ -1523,15 +1575,19 @@ def emit_new_turns_from_transcript( key = get_session_state_key(session_id, str(transcript_path)) session_state = get_session_state(state, key) - turns, session_state = get_new_turns_from_transcript(transcript_path, session_state) - if not turns: - save_session_state(state, key, session_state) - return 0 - 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, + ) + if not turns: + save_session_state(state, key, session_state) + return 0 + turns_to_emit = get_turns_to_emit(turns, session_state) emitted = emit_ready_turns( langfuse, From d362db1918a52552a6eb0617ab5a5864ebdf6f41 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Thu, 2 Jul 2026 17:15:41 +0200 Subject: [PATCH 21/30] fix(hook): avoid losing deferred async agent turns --- hooks/langfuse_hook.py | 138 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 11 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 7f96868..0338257 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -190,6 +190,10 @@ def get_session_id_and_transcript_path(payload: Dict[str, Any]) -> Optional[Tupl 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: @@ -549,6 +553,58 @@ def get_result_from_task_notification(row: Dict[str, Any]) -> str: result = _extract_xml_tag_value(notification_text, "result") return result if result is not None else notification_text +def get_deferred_agent_turn_rows_key(rows: List[Dict[str, Any]]) -> str: + stable_row_ids: List[str] = [] + for row in rows: + if not isinstance(row, dict): + continue + row_id = row.get("uuid") or row.get("requestId") or row.get("timestamp") + if row_id is not None: + stable_row_ids.append(str(row_id)) + if stable_row_ids: + return "|".join(stable_row_ids) + + try: + serialized_rows = json.dumps(rows, sort_keys=True, ensure_ascii=False) + except Exception: + serialized_rows = repr(rows) + return hashlib.sha256(serialized_rows.encode("utf-8")).hexdigest() + +def pop_deferred_agent_turn_rows( + session_state: SessionState, + tool_use_ids: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + selected_tool_use_ids = ( + tool_use_ids + if tool_use_ids is not None + else list(session_state.pending_agent_turns.keys()) + ) + rows_to_prepend: List[Dict[str, Any]] = [] + popped_row_keys = set() + + for tool_use_id in selected_tool_use_ids: + deferred_rows = session_state.pending_agent_turns.pop(tool_use_id, None) + if not isinstance(deferred_rows, list): + continue + + row_key = get_deferred_agent_turn_rows_key(deferred_rows) + if row_key in popped_row_keys: + continue + + popped_row_keys.add(row_key) + rows_to_prepend.extend(deferred_rows) + + if popped_row_keys: + # One deferred turn can wait on multiple async Agent/Task tool calls. + # Drop sibling entries that point at the same stored turn rows. + for tool_use_id, deferred_rows in list(session_state.pending_agent_turns.items()): + if not isinstance(deferred_rows, list): + continue + if get_deferred_agent_turn_rows_key(deferred_rows) in popped_row_keys: + session_state.pending_agent_turns.pop(tool_use_id, None) + + return rows_to_prepend + def prepend_deferred_agent_turn_rows( rows: List[Dict[str, Any]], session_state: SessionState, @@ -560,11 +616,33 @@ def prepend_deferred_agent_turn_rows( for row in rows: tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) if tool_use_id and tool_use_id in session_state.pending_agent_turns: - rows_with_deferred_turns.extend(session_state.pending_agent_turns.pop(tool_use_id)) + rows_with_deferred_turns.extend(pop_deferred_agent_turn_rows(session_state, [tool_use_id])) rows_with_deferred_turns.append(row) return rows_with_deferred_turns -def get_pending_agent_tool_use_ids(turn: Turn) -> List[str]: +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 is_async_agent_launch_result(tool_result_entry: Any) -> bool: + 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, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, +) -> 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)): @@ -576,17 +654,29 @@ def get_pending_agent_tool_use_ids(turn: Turn) -> List[str]: 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 - tool_result_content = tool_result_entry.get("content") if isinstance(tool_result_entry, dict) else None - tool_result_text = tool_result_content if isinstance(tool_result_content, str) else json.dumps(tool_result_content, ensure_ascii=False) - if "Async agent launched successfully" in tool_result_text: + has_subagent_transcript = ( + subagent_transcripts_by_tool_use_id is not None + and tool_use_id in subagent_transcripts_by_tool_use_id + ) + if has_subagent_transcript or 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) -> List[Turn]: +def get_turns_to_emit( + turns: List[Turn], + session_state: SessionState, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, + *, + 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) + pending_agent_tool_use_ids = get_pending_agent_tool_use_ids(turn, subagent_transcripts_by_tool_use_id) 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 for tool_use_id in pending_agent_tool_use_ids: session_state.pending_agent_turns[tool_use_id] = turn.rows debug(f"Deferred agent turn until task notification: {pending_agent_tool_use_ids}") @@ -786,13 +876,24 @@ 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) + + if rows: + rows = prepend_deferred_agent_turn_rows(rows, session_state, task_id_to_tool_use_id) + + if flush_deferred_agent_turns and session_state.pending_agent_turns: + deferred_rows = pop_deferred_agent_turn_rows(session_state) + if deferred_rows: + debug(f"Flushing {len(deferred_rows)} deferred agent row(s) without task notification") + rows = deferred_rows + rows + if not rows: return [], session_state - task_id_to_tool_use_id = get_task_id_to_tool_use_id(subagent_transcripts_by_tool_use_id) - rows = prepend_deferred_agent_turn_rows(rows, session_state, task_id_to_tool_use_id) return build_turns(rows, task_id_to_tool_use_id), session_state def get_subagent_transcripts_by_tool_use_id(transcript_path: Path) -> Dict[str, Dict[str, Any]]: @@ -1569,6 +1670,8 @@ def emit_new_turns_from_transcript( config: LangfuseConfig, session_id: str, transcript_path: Path, + *, + flush_deferred_agent_turns: bool = False, ) -> int: with FileLock(LOCK_FILE): state = load_hook_state() @@ -1583,12 +1686,18 @@ def emit_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) + turns_to_emit = get_turns_to_emit( + turns, + session_state, + subagent_transcripts_by_tool_use_id, + flush_deferred_agent_turns=flush_deferred_agent_turns, + ) emitted = emit_ready_turns( langfuse, session_id, @@ -1643,9 +1752,16 @@ def main() -> int: return 0 session_id, transcript_path = hook_context + flush_deferred_agent_turns = is_session_end_hook_payload(payload) try: - emitted = emit_new_turns_from_transcript(langfuse, config, session_id, transcript_path) + 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})") From 557323aa966a55b32dbf1afd57364088c5eccab7 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Fri, 3 Jul 2026 15:52:56 +0200 Subject: [PATCH 22/30] test(hook): cover Claude Code subagent transcript tracing --- pyproject.toml | 18 ++ tests/conftest.py | 133 +++++++++++++++ .../async_agent_completed/transcript.jsonl | 15 ++ .../subagents/agent-agent-complete.jsonl | 4 + .../subagents/agent-agent-complete.meta.json | 1 + .../async_agent_deferred/transcript.jsonl | 7 + .../subagents/agent-agent-deferred.jsonl | 2 + .../subagents/agent-agent-deferred.meta.json | 1 + .../nested_subagents/transcript.jsonl | 9 + .../subagents/agent-inner-agent.jsonl | 2 + .../subagents/agent-inner-agent.meta.json | 1 + .../subagents/agent-outer-agent.jsonl | 4 + .../subagents/agent-outer-agent.meta.json | 1 + .../transcripts/simple_turn/transcript.jsonl | 3 + .../transcript.jsonl | 8 + .../subagents/agent-task-fallback.jsonl | 2 + .../subagents/agent-task-fallback.meta.json | 1 + .../transcripts/tool_turn/transcript.jsonl | 4 + .../test_emit_new_turns_from_transcript.py | 60 +++++++ .../test_emit_turn_observations.py | 60 +++++++ tests/unit/test_async_agent_deferral.py | 65 ++++++++ tests/unit/test_hook_payload.py | 56 +++++++ tests/unit/test_langfuse_payload_builders.py | 96 +++++++++++ tests/unit/test_subagent_discovery.py | 52 ++++++ tests/unit/test_task_notifications.py | 63 +++++++ tests/unit/test_transcript_reader.py | 57 +++++++ tests/unit/test_turn_assembly.py | 93 +++++++++++ uv.lock | 156 ++++++++++++++++++ 28 files changed, 974 insertions(+) create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/transcripts/async_agent_completed/transcript.jsonl create mode 100644 tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.jsonl create mode 100644 tests/fixtures/transcripts/async_agent_completed/transcript/subagents/agent-agent-complete.meta.json create mode 100644 tests/fixtures/transcripts/async_agent_deferred/transcript.jsonl create mode 100644 tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.jsonl create mode 100644 tests/fixtures/transcripts/async_agent_deferred/transcript/subagents/agent-agent-deferred.meta.json create mode 100644 tests/fixtures/transcripts/nested_subagents/transcript.jsonl create mode 100644 tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.jsonl create mode 100644 tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-inner-agent.meta.json create mode 100644 tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.jsonl create mode 100644 tests/fixtures/transcripts/nested_subagents/transcript/subagents/agent-outer-agent.meta.json create mode 100644 tests/fixtures/transcripts/simple_turn/transcript.jsonl create mode 100644 tests/fixtures/transcripts/task_notification_task_id_fallback/transcript.jsonl create mode 100644 tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.jsonl create mode 100644 tests/fixtures/transcripts/task_notification_task_id_fallback/transcript/subagents/agent-task-fallback.meta.json create mode 100644 tests/fixtures/transcripts/tool_turn/transcript.jsonl create mode 100644 tests/integration/test_emit_new_turns_from_transcript.py create mode 100644 tests/integration/test_emit_turn_observations.py create mode 100644 tests/unit/test_async_agent_deferral.py create mode 100644 tests/unit/test_hook_payload.py create mode 100644 tests/unit/test_langfuse_payload_builders.py create mode 100644 tests/unit/test_subagent_discovery.py create mode 100644 tests/unit/test_task_notifications.py create mode 100644 tests/unit/test_transcript_reader.py create mode 100644 tests/unit/test_turn_assembly.py create mode 100644 uv.lock 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..49a3263 --- /dev/null +++ b/tests/integration/test_emit_new_turns_from_transcript.py @@ -0,0 +1,60 @@ +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")) + assert set(next(iter(state.values()))["pending_agent_turns"]) == {"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..df7ed28 --- /dev/null +++ b/tests/unit/test_async_agent_deferral.py @@ -0,0 +1,65 @@ +from __future__ import annotations + + +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, subagents) + + 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, subagents) + + assert turns_to_emit == [] + assert set(state.pending_agent_turns) == {"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, + subagents, + flush_deferred_agent_turns=True, + ) + + assert len(flushed_to_emit) == 1 + assert state.pending_agent_turns == {} + + +def test_popping_deferred_rows_deduplicates_same_turn_waiting_on_multiple_agents(hook_module): + shared_rows = [{"uuid": "row-1"}, {"uuid": "row-2"}] + state = hook_module.SessionState( + pending_agent_turns={ + "toolu_agent_a": shared_rows, + "toolu_agent_b": shared_rows, + } + ) + + rows = hook_module.pop_deferred_agent_turn_rows(state, ["toolu_agent_a"]) + + assert rows == shared_rows + assert state.pending_agent_turns == {} 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..288a4e1 --- /dev/null +++ b/tests/unit/test_langfuse_payload_builders.py @@ -0,0 +1,96 @@ +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"}]} + + +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" 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_task_notifications.py b/tests/unit/test_task_notifications.py new file mode 100644 index 0000000..33dad60 --- /dev/null +++ b/tests/unit/test_task_notifications.py @@ -0,0 +1,63 @@ +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_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" }, +] From 8ccfd82d137923b02e3ae7ab3508bb159d808519 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 11:58:47 +0200 Subject: [PATCH 23/30] fix(hook): validate payload before creating Langfuse client --- hooks/langfuse_hook.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 0338257..ecbbec6 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -1742,10 +1742,6 @@ def main() -> int: if config is None: return 0 - langfuse = create_langfuse_client(config) - if langfuse is None: - return 0 - payload = read_hook_payload() hook_context = get_session_id_and_transcript_path(payload) if hook_context is None: @@ -1754,6 +1750,10 @@ def main() -> int: session_id, transcript_path = hook_context flush_deferred_agent_turns = is_session_end_hook_payload(payload) + langfuse = create_langfuse_client(config) + if langfuse is None: + return 0 + try: emitted = emit_new_turns_from_transcript( langfuse, From e408f711bf4d2a411fd764443f076e0aeaa833c8 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 14:30:37 +0200 Subject: [PATCH 24/30] fix(hook): store deferred agent turns once and remove duplicates (no.8) --- hooks/langfuse_hook.py | 76 +++++++------------ .../test_emit_new_turns_from_transcript.py | 8 +- tests/unit/test_async_agent_deferral.py | 57 +++++++++++--- 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index ecbbec6..9da9677 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -264,18 +264,18 @@ 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: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + pending_agent_turns: 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 = s.get("pending_agent_turns") - if not isinstance(pending, dict): - pending = {} + pending_agent_turns = s.get("pending_agent_turns") + if not isinstance(pending_agent_turns, list): + pending_agent_turns = [] 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, + pending_agent_turns=pending_agent_turns, ) def update_session_state(global_state: Dict[str, Any], key: str, session_state: SessionState) -> None: @@ -283,7 +283,7 @@ def update_session_state(global_state: Dict[str, Any], key: str, session_state: "offset": session_state.offset, "buffer": session_state.buffer, "turn_count": session_state.turn_count, - "pending_agent_turns": session_state.pending_agent_turns or {}, + "pending_agent_turns": session_state.pending_agent_turns or [], "updated": datetime.now(timezone.utc).isoformat(), } @@ -553,55 +553,31 @@ def get_result_from_task_notification(row: Dict[str, Any]) -> str: result = _extract_xml_tag_value(notification_text, "result") return result if result is not None else notification_text -def get_deferred_agent_turn_rows_key(rows: List[Dict[str, Any]]) -> str: - stable_row_ids: List[str] = [] - for row in rows: - if not isinstance(row, dict): - continue - row_id = row.get("uuid") or row.get("requestId") or row.get("timestamp") - if row_id is not None: - stable_row_ids.append(str(row_id)) - if stable_row_ids: - return "|".join(stable_row_ids) - - try: - serialized_rows = json.dumps(rows, sort_keys=True, ensure_ascii=False) - except Exception: - serialized_rows = repr(rows) - return hashlib.sha256(serialized_rows.encode("utf-8")).hexdigest() - def pop_deferred_agent_turn_rows( session_state: SessionState, tool_use_ids: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: - selected_tool_use_ids = ( - tool_use_ids - if tool_use_ids is not None - else list(session_state.pending_agent_turns.keys()) - ) + selected_tool_use_ids = set(tool_use_ids) if tool_use_ids is not None else None rows_to_prepend: List[Dict[str, Any]] = [] - popped_row_keys = set() + remaining_pending_agent_turns: List[Dict[str, Any]] = [] - for tool_use_id in selected_tool_use_ids: - deferred_rows = session_state.pending_agent_turns.pop(tool_use_id, None) - if not isinstance(deferred_rows, list): + for pending_turn in session_state.pending_agent_turns: + if not isinstance(pending_turn, dict): continue - - row_key = get_deferred_agent_turn_rows_key(deferred_rows) - if row_key in popped_row_keys: + pending_tool_use_ids = pending_turn.get("pending_tool_use_ids") + rows = pending_turn.get("rows") + if not isinstance(pending_tool_use_ids, list) or not isinstance(rows, list): + continue + should_pop = ( + selected_tool_use_ids is None + or any(tool_use_id in selected_tool_use_ids for tool_use_id in pending_tool_use_ids) + ) + if should_pop: + rows_to_prepend.extend(rows) continue + remaining_pending_agent_turns.append(pending_turn) - popped_row_keys.add(row_key) - rows_to_prepend.extend(deferred_rows) - - if popped_row_keys: - # One deferred turn can wait on multiple async Agent/Task tool calls. - # Drop sibling entries that point at the same stored turn rows. - for tool_use_id, deferred_rows in list(session_state.pending_agent_turns.items()): - if not isinstance(deferred_rows, list): - continue - if get_deferred_agent_turn_rows_key(deferred_rows) in popped_row_keys: - session_state.pending_agent_turns.pop(tool_use_id, None) + session_state.pending_agent_turns = remaining_pending_agent_turns return rows_to_prepend @@ -615,7 +591,7 @@ def prepend_deferred_agent_turn_rows( rows_with_deferred_turns: List[Dict[str, Any]] = [] for row in rows: tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) - if tool_use_id and tool_use_id in session_state.pending_agent_turns: + if tool_use_id: rows_with_deferred_turns.extend(pop_deferred_agent_turn_rows(session_state, [tool_use_id])) rows_with_deferred_turns.append(row) return rows_with_deferred_turns @@ -677,8 +653,10 @@ def get_turns_to_emit( debug(f"Emitting async agent turn without task notification: {pending_agent_tool_use_ids}") turns_to_emit.append(turn) continue - for tool_use_id in pending_agent_tool_use_ids: - session_state.pending_agent_turns[tool_use_id] = turn.rows + 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) diff --git a/tests/integration/test_emit_new_turns_from_transcript.py b/tests/integration/test_emit_new_turns_from_transcript.py index 49a3263..05dadff 100644 --- a/tests/integration/test_emit_new_turns_from_transcript.py +++ b/tests/integration/test_emit_new_turns_from_transcript.py @@ -23,7 +23,7 @@ def test_emit_new_turns_from_completed_async_agent_transcript( 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"] == {} + assert next(iter(state.values()))["pending_agent_turns"] == [] def test_emit_new_turns_defers_async_agent_until_session_end_flush( @@ -44,7 +44,9 @@ def test_emit_new_turns_defers_async_agent_until_session_end_flush( assert emitted == 0 state = json.loads((isolated_hook_state / "langfuse_state.json").read_text(encoding="utf-8")) - assert set(next(iter(state.values()))["pending_agent_turns"]) == {"toolu_agent_deferred"} + 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, @@ -57,4 +59,4 @@ def test_emit_new_turns_defers_async_agent_until_session_end_flush( 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"] == {} + assert next(iter(state.values()))["pending_agent_turns"] == [] diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py index df7ed28..71d0d01 100644 --- a/tests/unit/test_async_agent_deferral.py +++ b/tests/unit/test_async_agent_deferral.py @@ -14,7 +14,7 @@ def test_completed_async_agent_turn_is_ready_to_emit( assert len(turns) == 1 assert len(turns_to_emit) == 1 - assert state.pending_agent_turns == {} + assert state.pending_agent_turns == [] result = turns[0].tool_results_by_id["toolu_agent_complete"] assert result["final_content"] == "Subagent summary is ready." @@ -31,7 +31,8 @@ def test_uncompleted_async_agent_turn_is_deferred_until_flush( turns_to_emit = hook_module.get_turns_to_emit(turns, state, subagents) assert turns_to_emit == [] - assert set(state.pending_agent_turns) == {"toolu_agent_deferred"} + 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, @@ -47,19 +48,57 @@ def test_uncompleted_async_agent_turn_is_deferred_until_flush( ) assert len(flushed_to_emit) == 1 - assert state.pending_agent_turns == {} + assert state.pending_agent_turns == [] -def test_popping_deferred_rows_deduplicates_same_turn_waiting_on_multiple_agents(hook_module): +def test_popping_deferred_rows_removes_whole_turn_waiting_on_multiple_agents(hook_module): shared_rows = [{"uuid": "row-1"}, {"uuid": "row-2"}] state = hook_module.SessionState( - pending_agent_turns={ - "toolu_agent_a": shared_rows, - "toolu_agent_b": shared_rows, - } + pending_agent_turns=[ + { + "pending_tool_use_ids": ["toolu_agent_a", "toolu_agent_b"], + "rows": shared_rows, + }, + ], ) rows = hook_module.pop_deferred_agent_turn_rows(state, ["toolu_agent_a"]) assert rows == shared_rows - assert state.pending_agent_turns == {} + assert state.pending_agent_turns == [] + + +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 From 72f653bbcb9d19d08ea8c7be0f97e58320c5998c Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 14:51:26 +0200 Subject: [PATCH 25/30] fix(hook): only resolve agent notifications from task rows (no.4) --- hooks/langfuse_hook.py | 3 ++ tests/unit/test_async_agent_deferral.py | 35 +++++++++++++++++++++++ tests/unit/test_task_notifications.py | 37 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 9da9677..9212abf 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -539,6 +539,9 @@ 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 diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py index 71d0d01..c1ce499 100644 --- a/tests/unit/test_async_agent_deferral.py +++ b/tests/unit/test_async_agent_deferral.py @@ -68,6 +68,41 @@ def test_popping_deferred_rows_removes_whole_turn_waiting_on_multiple_agents(hoo assert state.pending_agent_turns == [] +def test_prepend_deferred_rows_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, + }, + ], + ) + + rows = hook_module.prepend_deferred_agent_turn_rows(current_rows, state) + + assert rows == 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( diff --git a/tests/unit/test_task_notifications.py b/tests/unit/test_task_notifications.py index 33dad60..6c59a5a 100644 --- a/tests/unit/test_task_notifications.py +++ b/tests/unit/test_task_notifications.py @@ -34,6 +34,43 @@ def test_task_notification_extracts_tool_use_id_task_id_and_result(hook_module): 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, From 29ea00457c6618c7c2330557466c79cf4d2373b9 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 15:01:27 +0200 Subject: [PATCH 26/30] fix(hook): skip malformed subagent transcript lines (no.7) --- hooks/langfuse_hook.py | 29 ++++++++++++++++--- tests/unit/test_subagent_observations.py | 36 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_subagent_observations.py diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 9212abf..e5d2264 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -1477,10 +1477,8 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, path = subagent.get("path") if not isinstance(path, Path): return start_timestamp - try: - rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] - except Exception as e: - info(f"subagent transcript read failed ({path}): {type(e).__name__}: {e}") + rows = read_subagent_jsonl(path) + if rows is None: return start_timestamp turns = build_turns(rows) @@ -1536,6 +1534,29 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, 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 = [ 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", + ] From 45e959c33bfbd42deb444de61c79525e1b354804 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 16:02:45 +0200 Subject: [PATCH 27/30] fix(hook): rebuild deferred agent turns in isolation (no.1) --- hooks/langfuse_hook.py | 115 ++++++++---- tests/unit/test_async_agent_deferral.py | 235 +++++++++++++++++++++++- 2 files changed, 302 insertions(+), 48 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index e5d2264..87f53a5 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -556,48 +556,78 @@ def get_result_from_task_notification(row: Dict[str, Any]) -> str: result = _extract_xml_tag_value(notification_text, "result") return result if result is not None else notification_text -def pop_deferred_agent_turn_rows( +def _find_pending_agent_turn( session_state: SessionState, - tool_use_ids: Optional[List[str]] = None, -) -> List[Dict[str, Any]]: - selected_tool_use_ids = set(tool_use_ids) if tool_use_ids is not None else None - rows_to_prepend: List[Dict[str, Any]] = [] - remaining_pending_agent_turns: List[Dict[str, Any]] = [] - + tool_use_id: str, +) -> Optional[Dict[str, Any]]: for pending_turn in session_state.pending_agent_turns: if not isinstance(pending_turn, dict): continue - pending_tool_use_ids = pending_turn.get("pending_tool_use_ids") - rows = pending_turn.get("rows") - if not isinstance(pending_tool_use_ids, list) or not isinstance(rows, list): - continue - should_pop = ( - selected_tool_use_ids is None - or any(tool_use_id in selected_tool_use_ids for tool_use_id in pending_tool_use_ids) - ) - if should_pop: - rows_to_prepend.extend(rows) + if not isinstance(pending_turn.get("rows"), list): continue - remaining_pending_agent_turns.append(pending_turn) - - session_state.pending_agent_turns = remaining_pending_agent_turns - - return rows_to_prepend + 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 prepend_deferred_agent_turn_rows( +def resolve_deferred_agent_turns( rows: List[Dict[str, Any]], session_state: SessionState, task_id_to_tool_use_id: Optional[Dict[str, str]] = None, -) -> List[Dict[str, Any]]: +) -> 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, unmatched notifications stay in the batch. + """ if not session_state.pending_agent_turns: - return rows - rows_with_deferred_turns: List[Dict[str, Any]] = [] + return [], rows + + remaining_rows: List[Dict[str, Any]] = [] for row in rows: tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) - if tool_use_id: - rows_with_deferred_turns.extend(pop_deferred_agent_turn_rows(session_state, [tool_use_id])) - rows_with_deferred_turns.append(row) - return rows_with_deferred_turns + pending_turn = _find_pending_agent_turn(session_state, tool_use_id) if tool_use_id else None + if pending_turn is None: + remaining_rows.append(row) + continue + 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) + + # 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): @@ -863,19 +893,24 @@ def get_new_turns_from_transcript( 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) - if rows: - rows = prepend_deferred_agent_turn_rows(rows, session_state, task_id_to_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: - deferred_rows = pop_deferred_agent_turn_rows(session_state) - if deferred_rows: - debug(f"Flushing {len(deferred_rows)} deferred agent row(s) without task notification") - rows = deferred_rows + rows - - if not rows: - return [], session_state + 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 + + # 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 build_turns(rows, task_id_to_tool_use_id), session_state + 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.""" diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py index c1ce499..06fb4ad 100644 --- a/tests/unit/test_async_agent_deferral.py +++ b/tests/unit/test_async_agent_deferral.py @@ -1,5 +1,98 @@ 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_async_launch_result_row(uuid: str, tool_use_id: str, timestamp: str) -> dict[str, Any]: + return { + "type": "user", + "timestamp": timestamp, + "uuid": uuid, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": [ + { + "type": "text", + "text": ( + "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." + ), + } + ], + } + ], + }, + } + + +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, @@ -51,24 +144,74 @@ def test_uncompleted_async_agent_turn_is_deferred_until_flush( assert state.pending_agent_turns == [] -def test_popping_deferred_rows_removes_whole_turn_waiting_on_multiple_agents(hook_module): - shared_rows = [{"uuid": "row-1"}, {"uuid": "row-2"}] +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": shared_rows, + "rows": deferred_rows, }, ], ) - rows = hook_module.pop_deferred_agent_turn_rows(state, ["toolu_agent_a"]) + 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 rows == shared_rows + 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_prepend_deferred_rows_ignores_non_notification_tool_use_xml(hook_module): +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 = [ { @@ -92,9 +235,10 @@ def test_prepend_deferred_rows_ignores_non_notification_tool_use_xml(hook_module ], ) - rows = hook_module.prepend_deferred_agent_turn_rows(current_rows, state) + resolved, remaining = hook_module.resolve_deferred_agent_turns(current_rows, state) - assert rows == current_rows + assert resolved == [] + assert remaining == current_rows assert state.pending_agent_turns == [ { "pending_tool_use_ids": ["toolu_agent_a"], @@ -137,3 +281,78 @@ def test_multi_agent_turn_is_stored_once_with_all_waiting_tool_ids(hook_module): 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"] From 65f5fe22434268d6be53133a2c656d047997e448 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 17:10:29 +0200 Subject: [PATCH 28/30] fix(hook): defer only explicitly async agent turns (no.2) --- hooks/langfuse_hook.py | 42 ++++--- tests/unit/test_async_agent_deferral.py | 144 +++++++++++++++++++++--- 2 files changed, 157 insertions(+), 29 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 87f53a5..60d1a4f 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -637,7 +637,25 @@ def get_tool_result_text(tool_result_entry: Any) -> 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 @@ -648,10 +666,7 @@ def is_async_agent_launch_result(tool_result_entry: Any) -> bool: ) ) -def get_pending_agent_tool_use_ids( - turn: Turn, - subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, -) -> List[str]: +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)): @@ -663,24 +678,22 @@ def get_pending_agent_tool_use_ids( 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 - has_subagent_transcript = ( - subagent_transcripts_by_tool_use_id is not None - and tool_use_id in subagent_transcripts_by_tool_use_id - ) - if has_subagent_transcript or is_async_agent_launch_result(tool_result_entry): + # 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, - subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, *, 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, subagent_transcripts_by_tool_use_id) + 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}") @@ -720,13 +733,17 @@ def add_tool_result_row(row: Dict[str, Any], state: TurnAssemblyState) -> bool: 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: - state.tool_results_by_id[str(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( @@ -1732,7 +1749,6 @@ def emit_new_turns_from_transcript( turns_to_emit = get_turns_to_emit( turns, session_state, - subagent_transcripts_by_tool_use_id, flush_deferred_agent_turns=flush_deferred_agent_turns, ) emitted = emit_ready_turns( diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py index 06fb4ad..05d2b08 100644 --- a/tests/unit/test_async_agent_deferral.py +++ b/tests/unit/test_async_agent_deferral.py @@ -24,8 +24,14 @@ def make_assistant_row(uuid: str, message_id: str, content: list[dict[str, Any]] } -def make_async_launch_result_row(uuid: str, tool_use_id: str, timestamp: str) -> dict[str, Any]: - return { +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, @@ -35,21 +41,29 @@ def make_async_launch_result_row(uuid: str, tool_use_id: str, timestamp: str) -> { "type": "tool_result", "tool_use_id": tool_use_id, - "content": [ - { - "type": "text", - "text": ( - "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." - ), - } - ], + "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]: @@ -103,7 +117,7 @@ def test_completed_async_agent_turn_is_ready_to_emit( 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, subagents) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) assert len(turns) == 1 assert len(turns_to_emit) == 1 @@ -121,7 +135,7 @@ def test_uncompleted_async_agent_turn_is_deferred_until_flush( 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, subagents) + turns_to_emit = hook_module.get_turns_to_emit(turns, state) assert turns_to_emit == [] assert len(state.pending_agent_turns) == 1 @@ -136,7 +150,6 @@ def test_uncompleted_async_agent_turn_is_deferred_until_flush( flushed_to_emit = hook_module.get_turns_to_emit( flushed_turns, state, - subagents, flush_deferred_agent_turns=True, ) @@ -356,3 +369,102 @@ def test_notification_before_first_assistant_row_does_not_drop_the_turn(hook_mod 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 From f8534cd8d6ef11516889630157f969dda2e85c05 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 17:25:52 +0200 Subject: [PATCH 29/30] fix(hook): merge ready async results into generation input (no.5) --- hooks/langfuse_hook.py | 13 ++-- tests/unit/test_langfuse_payload_builders.py | 72 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 60d1a4f..89be3e8 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -1068,13 +1068,12 @@ def build_generation_input( ) -> Any: if assistant_index == 0: return {"role": "user", "content": user_text} - if previous_tool_results: - return {"role": "tool", "tool_results": previous_tool_results} - if ready_async_tool_results: - return { - "role": "tool", - "tool_results": [result["tool_result"] for result in ready_async_tool_results], - } + # 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 build_generation_output(assistant_text: str, tool_uses: List[Dict[str, Any]]) -> Dict[str, Any]: diff --git a/tests/unit/test_langfuse_payload_builders.py b/tests/unit/test_langfuse_payload_builders.py index 288a4e1..75a7e1d 100644 --- a/tests/unit/test_langfuse_payload_builders.py +++ b/tests/unit/test_langfuse_payload_builders.py @@ -18,6 +18,25 @@ def test_generation_input_tracks_user_tool_and_async_tool_contexts(hook_module): [], [{"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): @@ -94,3 +113,56 @@ def test_trace_metadata_includes_session_turn_project_and_branch( 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." From 17cdf79b0977dbfba1fe87edd6877e4cc189b4b1 Mon Sep 17 00:00:00 2001 From: Milana Gurbanova Date: Tue, 7 Jul 2026 17:54:29 +0200 Subject: [PATCH 30/30] fix(hook): retry unresolved task notifications across hook runs (no.6) --- hooks/langfuse_hook.py | 61 +++++++++-- tests/unit/test_async_agent_deferral.py | 128 ++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 89be3e8..ec5159a 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -52,6 +52,9 @@ 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 @@ -265,17 +268,24 @@ class SessionState: 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: @@ -284,6 +294,7 @@ def update_session_state(global_state: Dict[str, Any], key: str, session_state: "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(), } @@ -584,24 +595,50 @@ def resolve_deferred_agent_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, unmatched notifications stay in the batch. + 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. """ - if not session_state.pending_agent_turns: - return [], rows - remaining_rows: List[Dict[str, Any]] = [] - for row in rows: - tool_use_id = get_tool_use_id_for_task_notification(row, task_id_to_tool_use_id) - pending_turn = _find_pending_agent_turn(session_state, tool_use_id) if tool_use_id else None - if pending_turn is None: - remaining_rows.append(row) - continue + 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]] = [] @@ -918,6 +955,10 @@ def get_new_turns_from_transcript( 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). diff --git a/tests/unit/test_async_agent_deferral.py b/tests/unit/test_async_agent_deferral.py index 05d2b08..5f88a9f 100644 --- a/tests/unit/test_async_agent_deferral.py +++ b/tests/unit/test_async_agent_deferral.py @@ -468,3 +468,131 @@ def test_launch_text_fallback_defers_rows_without_tool_use_result(hook_module): 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]