From 1d72766c8d5a37ce2e695698af4d7045c84b9191 Mon Sep 17 00:00:00 2001 From: a-j-i-n-k-y-a Date: Mon, 12 Oct 2026 12:39:32 +0530 Subject: [PATCH 1/2] feat(hook): name traces after the session title Every trace was named "Claude Code Turn", so traces were indistinguishable in Langfuse. Claude Code writes the session title into the transcript as its own row ("custom-title" from /rename, "ai-title" auto-generated); use it as the trace name and keep the constant as the fallback for sessions without one. A rename wins over the auto-generated title, and within a type the last row read wins. Titles are kept in session state because each firing only reads the rows appended since the last one. Co-Authored-By: Claude Opus 5 --- hooks/langfuse_hook.py | 67 ++++++++++- tests/unit/test_transcript_reader.py | 162 +++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index cdd8f64..09cc4f3 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -537,6 +537,10 @@ class SessionState: # partially emitted turn across firings and across the open -> closed -> # deferred transitions; entries are dropped once the turn is finalized. turn_progress: Dict[str, Dict[str, Any]] = field(default_factory=dict) + # Session titles seen in the transcript, keyed by the row type that + # carried them ("custom-title", "ai-title"). Only new rows are read per + # firing, so the titles are kept here to stay available to later ones. + titles_by_type: Dict[str, str] = field(default_factory=dict) def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: s = global_state.get(key, {}) @@ -555,6 +559,9 @@ def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: turn_progress = s.get("turn_progress") if not isinstance(turn_progress, dict): turn_progress = {} + titles_by_type = s.get("titles_by_type") + if not isinstance(titles_by_type, dict): + titles_by_type = {} return SessionState( offset=int(s.get("offset", 0)), buffer=str(s.get("buffer", "")), @@ -564,6 +571,7 @@ def get_session_state(global_state: Dict[str, Any], key: str) -> SessionState: open_turn=open_turn, turn_numbers=turn_numbers, turn_progress=turn_progress, + titles_by_type=titles_by_type, ) def update_session_state(global_state: Dict[str, Any], key: str, session_state: SessionState) -> None: @@ -576,6 +584,7 @@ def update_session_state(global_state: Dict[str, Any], key: str, session_state: "open_turn": session_state.open_turn or {}, "turn_numbers": session_state.turn_numbers or {}, "turn_progress": session_state.turn_progress or {}, + "titles_by_type": session_state.titles_by_type or {}, "updated": datetime.now(timezone.utc).isoformat(), } @@ -915,6 +924,52 @@ def read_new_jsonl(transcript_path: Path, session_state: SessionState) -> Tuple[ return msgs, session_state +# ----------------- Session title ----------------- +# Claude Code writes the session title into the transcript as its own row: +# "custom-title" (set by /rename) or "ai-title" (auto-generated). A row is +# appended again whenever the title changes, so the last occurrence of a type +# is that type's current title. Listed in preference order. +SESSION_TITLE_ROW_FIELDS: Tuple[Tuple[str, str], ...] = ( + ("custom-title", "customTitle"), + ("ai-title", "aiTitle"), +) +MAX_SESSION_TITLE_CHARS = 200 + + +def clean_session_title(value: Any) -> Optional[str]: + """Trimmed, length-bounded title, or None for anything unusable.""" + if not isinstance(value, str): + return None + title = value.strip() + if not title: + return None + return title[:MAX_SESSION_TITLE_CHARS] + + +def update_session_titles(session_state: SessionState, rows: List[Dict[str, Any]]) -> None: + """Record the session titles carried by these rows.""" + for row in rows: + if not isinstance(row, dict): + continue + for row_type, title_field in SESSION_TITLE_ROW_FIELDS: + if row.get("type") != row_type: + continue + title = clean_session_title(row.get(title_field)) + if title is not None: + session_state.titles_by_type[row_type] = title + debug(f"Session title from {row_type} row") + + +def get_session_title(session_state: SessionState) -> Optional[str]: + """The session's title: the operator's own rename wins over the + auto-generated one, whichever row came last in the transcript.""" + for row_type, _ in SESSION_TITLE_ROW_FIELDS: + title = clean_session_title(session_state.titles_by_type.get(row_type)) + if title is not None: + return title + return None + + # ----------------- Turn assembly ----------------- @dataclass class Turn: @@ -1574,6 +1629,8 @@ def get_new_turns_from_transcript( f"{session_id}; not checking for copied history" ) + update_session_titles(session_state, rows) + # Re-attach the trailing open turn from the previous run. Stop fires # multiple times within one logical turn, so a batch can begin with # user-less continuation rows (task notifications, assistant rows); with @@ -2029,7 +2086,8 @@ def collect_subagent_skill_tags( add_skill_tags_from_rows(rows, names, "subagent-skill:") return names -# Constant on purpose: a shared trace name keeps Langfuse name-based grouping usable. +# Trace name for sessions without a title; a shared name keeps Langfuse +# name-based grouping usable for them. TRACE_NAME = "Claude Code Turn" def get_trace_tags( @@ -3198,7 +3256,8 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, trace_seed: Optional[str] = None, parent_context: Optional[Tuple[str, str]] = None, workflow_agent_transcripts_by_run_id: Optional[WorkflowAgentTranscriptsByRunId] = None, - history_prefix: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + history_prefix: Optional[List[Dict[str, Any]]] = None, + session_title: Optional[str] = None) -> Dict[str, Any]: """Emit a turn, resuming from prior firings' progress. With no progress and close=True this is the classic one-shot emission. @@ -3220,7 +3279,7 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, attribute_propagation = propagate_attributes( session_id=session_id, user_id=user_id, - trace_name=TRACE_NAME, + trace_name=session_title or TRACE_NAME, tags=get_trace_tags(turn, subagent_transcripts_by_tool_use_id), ) else: @@ -3328,6 +3387,7 @@ def emit_and_close_ready_turns( if session_history is not None else None ), + session_title=get_session_title(session_state), ) except Exception as e: # Log at INFO so SDK incompatibilities (and other emit failures) @@ -3398,6 +3458,7 @@ def emit_ready_observations_of_open_turn( if session_history is not None else None ), + session_title=get_session_title(session_state), ) session_state.turn_progress[user_row_uuid] = progress except Exception as e: diff --git a/tests/unit/test_transcript_reader.py b/tests/unit/test_transcript_reader.py index 883d002..38058ea 100644 --- a/tests/unit/test_transcript_reader.py +++ b/tests/unit/test_transcript_reader.py @@ -1,6 +1,10 @@ from __future__ import annotations +import contextlib import json +from pathlib import Path + +import pytest def test_read_new_jsonl_reads_once_then_advances_offset( @@ -101,3 +105,161 @@ def write(rows: list[dict]) -> None: assert state.pending_agent_turns == [] assert state.pending_task_notifications == [] assert state.open_turn == {} + + +# ----------------- Session title ----------------- + +TITLED_SESSION_ID = "session-titled" + + +def custom_title_row(value: object) -> dict: + return {"type": "custom-title", "customTitle": value, "sessionId": TITLED_SESSION_ID} + + +def ai_title_row(value: object) -> dict: + return {"type": "ai-title", "aiTitle": value, "sessionId": TITLED_SESSION_ID} + + +def title_of(hook_module, rows: list[dict]) -> str | None: + state = hook_module.SessionState() + hook_module.update_session_titles(state, rows) + return hook_module.get_session_title(state) + + +def conversation_rows(name: str) -> list[dict]: + return [ + {"type": "user", "timestamp": "2026-01-01T00:00:00.000Z", "uuid": f"{name}-user", + "sessionId": TITLED_SESSION_ID, "message": {"role": "user", "content": "Question."}}, + {"type": "assistant", "timestamp": "2026-01-01T00:00:01.000Z", "uuid": f"{name}-assistant", + "sessionId": TITLED_SESSION_ID, + "message": {"id": f"{name}-msg", "role": "assistant", "model": "m", + "content": [{"type": "text", "text": "Answer."}]}}, + ] + + +def write_transcript(directory: Path, rows: list[dict]) -> Path: + transcript = directory / f"{TITLED_SESSION_ID}.jsonl" + transcript.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8") + return transcript + + +def record_propagated_trace_names(hook_module, monkeypatch: pytest.MonkeyPatch) -> list[str | None]: + names: list[str | None] = [] + + @contextlib.contextmanager + def recording_propagate(**kwargs): + names.append(kwargs.get("trace_name")) + yield + + monkeypatch.setattr(hook_module, "propagate_attributes", recording_propagate) + return names + + +def test_a_custom_title_wins_over_an_ai_title_whichever_row_came_last(hook_module): + assert title_of(hook_module, [ + custom_title_row("Meeting Module"), + ai_title_row("Langfuse port 5432 conflict"), + ]) == "Meeting Module" + assert title_of(hook_module, [ + ai_title_row("Langfuse port 5432 conflict"), + custom_title_row("Meeting Module"), + ]) == "Meeting Module" + + +def test_the_last_title_row_of_a_type_replaces_the_earlier_one(hook_module): + assert title_of(hook_module, [ + custom_title_row("First name"), + custom_title_row("Second name"), + ]) == "Second name" + assert title_of(hook_module, [ + ai_title_row("First guess"), + ai_title_row("Second guess"), + ]) == "Second guess" + + +def test_an_ai_title_is_used_until_the_session_is_renamed(hook_module): + state = hook_module.SessionState() + hook_module.update_session_titles(state, [ai_title_row("Langfuse port 5432 conflict")]) + assert hook_module.get_session_title(state) == "Langfuse port 5432 conflict" + + hook_module.update_session_titles(state, [custom_title_row("Meeting Module")]) + assert hook_module.get_session_title(state) == "Meeting Module" + + +def test_unusable_title_values_are_ignored(hook_module): + assert title_of(hook_module, [custom_title_row("")]) is None + assert title_of(hook_module, [custom_title_row(" ")]) is None + assert title_of(hook_module, [custom_title_row(None)]) is None + assert title_of(hook_module, [custom_title_row(42)]) is None + assert title_of(hook_module, [{"type": "custom-title", "sessionId": TITLED_SESSION_ID}]) is None + assert title_of(hook_module, [{"type": "user", "customTitle": "Not a title row"}]) is None + + # A usable earlier title is kept, not replaced by the unusable one. + assert title_of(hook_module, [ + custom_title_row("Meeting Module"), + custom_title_row(" "), + ]) == "Meeting Module" + + +def test_a_title_is_trimmed_and_capped(hook_module): + assert title_of(hook_module, [custom_title_row(" Meeting Module \n")]) == "Meeting Module" + + capped = title_of(hook_module, [custom_title_row("x" * 5000)]) + assert capped == "x" * hook_module.MAX_SESSION_TITLE_CHARS + assert len(capped) == 200 + + +def test_titles_round_trip_through_the_state_file_representation(hook_module): + state = hook_module.SessionState() + hook_module.update_session_titles(state, [ + ai_title_row("Langfuse port 5432 conflict"), + custom_title_row("Meeting Module"), + ]) + + global_state: dict = {} + hook_module.update_session_state(global_state, "key", state) + assert global_state["key"]["titles_by_type"] == { + "ai-title": "Langfuse port 5432 conflict", + "custom-title": "Meeting Module", + } + + restored = hook_module.get_session_state(global_state, "key") + assert hook_module.get_session_title(restored) == "Meeting Module" + + +def test_the_session_title_is_the_trace_name_and_outlives_the_firing_that_saw_it( + hook_module, fake_langfuse, isolated_hook_state, tmp_path, monkeypatch: pytest.MonkeyPatch +): + transcript = write_transcript( + tmp_path, + conversation_rows("turn-a") + [custom_title_row("Meeting Module")] + conversation_rows("turn-b"), + ) + trace_names = record_propagated_trace_names(hook_module, monkeypatch) + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + + assert hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, TITLED_SESSION_ID, transcript) == 1 + + # Second firing: the title row sits behind the read offset, so only the + # title persisted in session state can name the trace. + assert hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, TITLED_SESSION_ID, transcript, + flush_deferred_agent_turns=True) == 1 + + assert len(trace_names) >= 2 + assert set(trace_names) == {"Meeting Module"} + + +def test_the_trace_name_falls_back_to_the_constant_without_a_title_row( + hook_module, fake_langfuse, isolated_hook_state, tmp_path, monkeypatch: pytest.MonkeyPatch +): + transcript = write_transcript(tmp_path, conversation_rows("turn-a") + conversation_rows("turn-b")) + trace_names = record_propagated_trace_names(hook_module, monkeypatch) + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + + hook_module.emit_new_turns_from_transcript(fake_langfuse, config, TITLED_SESSION_ID, transcript) + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, TITLED_SESSION_ID, transcript, flush_deferred_agent_turns=True) + + assert trace_names + assert set(trace_names) == {hook_module.TRACE_NAME} From f42924f72277fb2a6f119b517bde465b8fd2baa8 Mon Sep 17 00:00:00 2001 From: a-j-i-n-k-y-a Date: Mon, 12 Oct 2026 12:39:32 +0530 Subject: [PATCH 2/2] test: stop the concurrent-session state test from ageing out The entry it plants carried a hardcoded timestamp. Once that date fell outside the 30-day retention window, save_hook_state pruned the entry and the test failed for a reason it was not written to catch. Co-Authored-By: Claude Opus 5 --- tests/integration/test_state_lock_contention.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_state_lock_contention.py b/tests/integration/test_state_lock_contention.py index 899105b..4b7734a 100644 --- a/tests/integration/test_state_lock_contention.py +++ b/tests/integration/test_state_lock_contention.py @@ -111,7 +111,7 @@ def test_entry_of_another_session_survives_the_emit( def other_session_saves() -> None: with hook_module.FileLock(hook_module.LOCK_FILE): state = hook_module.load_hook_state() - state["other-session-key"] = {"offset": 42, "updated": "2026-09-10T00:00:00+00:00"} + state["other-session-key"] = {"offset": 42, "updated": _now_iso()} hook_module.save_hook_state(state) _during_emit(hook_module, monkeypatch, other_session_saves)