diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 3ca79363..ea122d05 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -18,6 +18,8 @@ from .cache import CacheManager from .utils import ( + coalesce_trunk_session_id, + collect_trunk_session_ids, format_timestamp_range, get_parent_session_id, get_project_display_name, @@ -1431,8 +1433,8 @@ def compute_session_data( ): continue - session_id = get_parent_session_id(getattr(message, "sessionId", "")) - if not session_id or session_id in warmup: + session_id = coalesce_trunk_session_id(message, warmup) + if not session_id: continue if session_id not in result: @@ -1689,10 +1691,19 @@ def _generate_paginated_html( total_output_tokens = 0 total_cache_creation_tokens = 0 total_cache_read_tokens = 0 + # The cached count must be the sessions-table sum, because + # is_page_stale() compares it against SUM(sessions.message_count) + # on the next run. len(page_messages) counts what's rendered + # (including Summary/AiTitle/Attachment entries that + # compute_session_data skips), so caching it left any page where + # the two rules diverge permanently "message_count_changed" — + # regenerating on every single run. + page_sessions_message_count = 0 for session_id in page_session_ids: if session_id in session_data: s = session_data[session_id] + page_sessions_message_count += s.message_count if s.first_timestamp and ( first_timestamp is None or s.first_timestamp < first_timestamp ): @@ -1778,7 +1789,7 @@ def _generate_paginated_html( html_path=html_path, page_size_config=page_size, session_ids=page_session_ids, - message_count=page_message_count, + message_count=page_sessions_message_count, first_timestamp=first_timestamp, last_timestamp=last_timestamp, total_input_tokens=total_input_tokens, @@ -2046,16 +2057,9 @@ def convert_jsonl_to( assert cache_manager is not None # Ensured by use_pagination condition # Use cached session data if available, otherwise build from messages if cached_data is not None: - warmup_session_ids = get_warmup_session_ids(messages) - current_session_ids: set[str] = set() - for message in messages: - session_id = getattr(message, "sessionId", "") - if ( - session_id - and session_id not in warmup_session_ids - and not is_agent_session(session_id) - ): - current_session_ids.add(session_id) + current_session_ids = collect_trunk_session_ids( + messages, get_warmup_session_ids(messages) + ) session_data = { session_id: session_cache for session_id, session_cache in cached_data.sessions.items() @@ -2398,20 +2402,14 @@ def _generate_individual_session_files( ext = get_file_extension(format) suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps) - # Pre-compute warmup sessions to exclude them - warmup_session_ids = get_warmup_session_ids(messages) - - # Find all unique session IDs (excluding warmup and agent sessions) - session_ids: set[str] = set() - for message in messages: - if hasattr(message, "sessionId"): - session_id: str = getattr(message, "sessionId") - if ( - session_id - and session_id not in warmup_session_ids - and not is_agent_session(session_id) - ): - session_ids.add(session_id) + # Find all unique session IDs, excluding warmup sessions and + # coalescing agent sessionIds to their trunk — same rule as + # compute_session_data() when it writes the sessions table. + # Dropping (rather than coalescing) agent ids left agent-sidechain- + # only sessions in the sessions table but never rendered, so + # get_stale_sessions() flagged them "not_cached" on every run — + # regenerating the project forever without ever writing the file. + session_ids = collect_trunk_session_ids(messages, get_warmup_session_ids(messages)) # Get session data from cache for better titles session_data: dict[str, Any] = {} @@ -3154,10 +3152,8 @@ def _rel_to_index(p: Path) -> str: ), ): continue - if not hasattr(_msg, "sessionId"): - continue - _sid = get_parent_session_id(getattr(_msg, "sessionId", "")) - if not _sid or _sid in warmup_for_teams: + _sid = coalesce_trunk_session_id(_msg, warmup_for_teams) + if not _sid: continue _tn = getattr(_msg, "teamName", None) if _tn and _sid not in team_name_per_session: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 91d60450..b676ab08 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -692,6 +692,45 @@ def get_warmup_session_ids(messages: list[TranscriptEntry]) -> set[str]: return warmup_sessions +def coalesce_trunk_session_id( + message: TranscriptEntry, warmup_session_ids: set[str] +) -> str: + """Return ``message``'s sessionId coalesced to its trunk, or ``""``. + + The shared normalization every session-collection site must agree + on: agent synthetic sessionIds (``{trunk}#agent-{id}``) coalesce to + their trunk via ``get_parent_session_id``, and warmup-only sessions + are excluded. Returns ``""`` for entries without a sessionId (e.g. + ``SummaryTranscriptEntry``) or whose trunk is a warmup session. + """ + session_id = getattr(message, "sessionId", "") or "" + if session_id: + session_id = get_parent_session_id(session_id) + if session_id in warmup_session_ids: + return "" + return session_id + + +def collect_trunk_session_ids( + messages: list[TranscriptEntry], warmup_session_ids: set[str] +) -> set[str]: + """Collect the distinct trunk sessionIds present in ``messages``. + + Coalescing (rather than dropping) agent sessionIds matters: a fork + session whose own messages were all deduplicated into the original + session can survive ONLY through its agent sidechains, and it must + still claim its page/file slot or its messages are grouped under a + session no output owns and vanish (regenerating forever — see + ``_generate_individual_session_files``). + """ + session_ids: set[str] = set() + for message in messages: + session_id = coalesce_trunk_session_id(message, warmup_session_ids) + if session_id: + session_ids.add(session_id) + return session_ids + + # Artifact favicons are 1-2 emoji (ZWJ sequences can span ~a dozen code # points); anything longer is malformed input not worth echoing inline. # Shared by the HTML and Markdown renderers so the cap can't drift.