From 228ecbcede1dd3cbcded989f009ca2153a66de38 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Jun 2026 18:06:55 +0200 Subject: [PATCH 1/3] Keep recaps visible at all detail levels; add --no-recaps (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recaps (away_summary) are themselves a high-level summary of activity, so they should stay visible at every detail level rather than being dropped at LOW and below (an oversight from #141). Change AwaySummaryMessage's detail_visibility from HIGH to USER_ONLY so recaps survive full → user-only, and add a --no-recaps flag to suppress them at any level (including FULL). - models.py: AwaySummaryMessage.detail_visibility = USER_ONLY. - renderer.py: Renderer.no_recaps attr; generate_template_messages + _ghost_template_by_detail gain no_recaps (recaps ghosted when set); run the ghost pass when no_recaps is set even at FULL; get_renderer wires it. - converter.py / cli.py: thread no_recaps through (mirrors no_timestamps); new --no-recaps CLI flag. - Resulting matrix: minimal → user+agent+recaps; minimal --no-recaps → user+agent; user-only → user+recaps; user-only --no-recaps → user only. Tests: rewrite the away_summary detail-level tests (visible at every level + --no-recaps suppresses everywhere), add an end-to-end matrix test, and allow the recap "system" type in the minimal real-projects assertion. dev-docs (messages.md, application_model.md) updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude_code_log/cli.py | 15 ++++ claude_code_log/converter.py | 12 +++ claude_code_log/html/renderer.py | 5 +- claude_code_log/markdown/renderer.py | 5 +- claude_code_log/models.py | 12 +-- claude_code_log/renderer.py | 23 ++++- dev-docs/application_model.md | 7 ++ dev-docs/messages.md | 2 +- test/test_away_summary.py | 129 +++++++++++++++++++++------ test/test_detail_levels.py | 7 +- 10 files changed, 178 insertions(+), 39 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 766aa990..25f02da3 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -705,6 +705,17 @@ def _validate_git_link_template(template: str) -> None: "error) if combined with --format html / --format json." ), ) +@click.option( + "--no-recaps", + is_flag=True, + help=( + "Suppress '※ recap' (away-summary) messages. Recaps are otherwise " + "shown at every detail level — they are themselves a high-level " + "summary of activity (#179). Use this to get a 'really user-only' " + "view (--detail user-only --no-recaps) or to drop the recap/agent " + "redundancy at --detail minimal." + ), +) @click.option( "--debug", is_flag=True, @@ -735,6 +746,7 @@ def main( compact: bool, git_link: Optional[str], no_timestamps: bool, + no_recaps: bool, debug: bool, ) -> None: """Convert Claude transcript JSONL files to HTML or Markdown. @@ -1004,6 +1016,7 @@ def main( detail=detail_level, compact=compact, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) click.echo(f"Successfully exported session to {output_path}") if open_browser: @@ -1064,6 +1077,7 @@ def main( filter_path=filter_path, write_combined=write_combined, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) # Count processed projects @@ -1123,6 +1137,7 @@ def main( update_cache=output is None, write_combined=write_combined, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) if input_path.is_file(): click.echo(f"Successfully converted {input_path} to {output_path}") diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 86fe6702..05ceeb93 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -1286,6 +1286,7 @@ def _generate_paginated_html( session_tree: Optional[SessionTree] = None, detail: DetailLevel = DetailLevel.FULL, compact: bool = False, + no_recaps: bool = False, ) -> Path: """Generate paginated HTML files for combined transcript. @@ -1449,6 +1450,7 @@ def _generate_paginated_html( page_renderer = HtmlRenderer() page_renderer.detail = detail page_renderer.compact = compact + page_renderer.no_recaps = no_recaps html_content = page_renderer.generate( page_messages, page_title, @@ -1525,6 +1527,7 @@ def convert_jsonl_to( output_root: Optional[Path] = None, write_combined: bool = True, no_timestamps: bool = False, + no_recaps: bool = False, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -1659,6 +1662,7 @@ def convert_jsonl_to( detail=detail, compact=compact, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) # Decide whether to use pagination (HTML only, directory mode, no date filter) @@ -1720,6 +1724,7 @@ def convert_jsonl_to( session_tree=session_tree, detail=detail, compact=compact, + no_recaps=no_recaps, ) else: # Use single-file generation for small projects or filtered views @@ -1784,6 +1789,7 @@ def convert_jsonl_to( compact=compact, write_combined=write_combined, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) return output_path @@ -1966,6 +1972,7 @@ def _generate_individual_session_files( compact: bool = False, write_combined: bool = True, no_timestamps: bool = False, + no_recaps: bool = False, ) -> int: """Generate individual files for each session in the specified format. @@ -2014,6 +2021,7 @@ def _generate_individual_session_files( detail=detail, compact=compact, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) regenerated_count = 0 @@ -2118,6 +2126,7 @@ def generate_single_session_file( detail: DetailLevel = DetailLevel.FULL, compact: bool = False, no_timestamps: bool = False, + no_recaps: bool = False, ) -> Path: """Generate a single session output file for the given session ID. @@ -2236,6 +2245,7 @@ def generate_single_session_file( detail=detail, compact=compact, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) session_content = renderer.generate_session( session_messages, matched_id, session_title, cache_manager, output_dir @@ -2307,6 +2317,7 @@ def process_projects_hierarchy( filter_path: Optional[str] = None, write_combined: bool = True, no_timestamps: bool = False, + no_recaps: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -2555,6 +2566,7 @@ def _rel_to_index(p: Path) -> str: output_root=(dest_dir if dest_dir != project_dir else None), write_combined=write_combined, no_timestamps=no_timestamps, + no_recaps=no_recaps, ) # Track timing diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 3a42b016..14f53992 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -1440,7 +1440,10 @@ def _generate_inner( # Get root messages (tree) and session navigation from format-neutral renderer root_messages, session_nav, ctx = generate_template_messages( - messages, session_tree=session_tree, detail=self.detail + messages, + session_tree=session_tree, + detail=self.detail, + no_recaps=self.no_recaps, ) # Snapshot the teammate-color map onto the renderer so per-message # format methods can consult it without threading ctx through every diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 0537a6c9..acc5543a 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -2063,7 +2063,10 @@ def _generate_inner( # Get root messages (tree), session navigation, and rendering context root_messages, session_nav, ctx = generate_template_messages( - messages, session_tree=session_tree, detail=self.detail + messages, + session_tree=session_tree, + detail=self.detail, + no_recaps=self.no_recaps, ) self._ctx = ctx self._teammate_colors_by_session = { diff --git a/claude_code_log/models.py b/claude_code_log/models.py index cc5b507a..e5c035c2 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -534,11 +534,13 @@ class AwaySummaryMessage(MessageContent): and the level-bearing SystemMessage (info/warning/error). """ - # Recaps are narrative content, not noise: visible at FULL and HIGH, - # dropped at LOW and below (alongside bash/thinking). Declared via the - # class-attribute detail-visibility mechanism so the rule lives with the - # content type rather than in renderer.py's exclude registries. - detail_visibility: ClassVar[DetailLevel] = DetailLevel.HIGH + # Recaps are a high-level summary of activity, so they stay visible at + # EVERY detail level — including user-only, where "user said this, agent + # did that (just the recap)" is exactly the wanted view (#179). Declared at + # the least-verbose threshold (USER_ONLY) via the class-attribute + # detail-visibility mechanism. Suppress them explicitly with ``--no-recaps`` + # (handled in ``_ghost_template_by_detail``), not by lowering this. + detail_visibility: ClassVar[DetailLevel] = DetailLevel.USER_ONLY text: str # Recap prose; may contain light markdown. diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2bea38e6..92730b49 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -666,6 +666,7 @@ def generate_template_messages( messages: list[TranscriptEntry], session_tree: Optional["SessionTree"] = None, detail: DetailLevel | str = DetailLevel.FULL, + no_recaps: bool = False, ) -> Tuple[list[TemplateMessage], list[dict[str, Any]], RenderingContext]: """Generate root messages and session navigation from transcript messages. @@ -770,9 +771,11 @@ def generate_template_messages( # (``session_first_message``, ``parent_message_index``, # ``junction_forward_links``) so dropped fork-points don't leave # dead ``#msg-d-{N}`` links. - if detail != DetailLevel.FULL: + # ``--no-recaps`` suppresses recaps even at FULL, so run the ghost pass + # whenever filtering OR recap suppression is requested (#179). + if detail != DetailLevel.FULL or no_recaps: with log_timing(f"Detail post-render filter ({detail.value})", t_start): - _ghost_template_by_detail(ctx, detail) + _ghost_template_by_detail(ctx, detail, no_recaps=no_recaps) # Prepare session navigation data (uses ctx for session header indices) session_nav: list[dict[str, Any]] = [] @@ -3395,6 +3398,7 @@ def _drop_anchor_refs_into_ghosts(ctx: RenderingContext) -> None: def _ghost_template_by_detail( ctx: RenderingContext, detail: DetailLevel, + no_recaps: bool = False, ) -> None: """Ghost (set to None) ctx.messages slots that aren't visible at ``detail``. @@ -3457,6 +3461,12 @@ def _ghost_template_by_detail( ): visible = False + # ``--no-recaps`` (#179): recaps are otherwise visible at every level + # (AwaySummaryMessage.detail_visibility == USER_ONLY); this is the + # explicit opt-out, applied regardless of detail level. + if visible and no_recaps and isinstance(msg.content, AwaySummaryMessage): + visible = False + if not visible: ctx.messages[idx] = None @@ -4270,6 +4280,10 @@ class Renderer: detail: DetailLevel = DetailLevel.FULL compact: bool = False + # When True, suppress ``※ recap`` (away_summary) messages at every detail + # level (#179). Recaps are otherwise always visible (see + # ``AwaySummaryMessage.detail_visibility``). + no_recaps: bool = False # Output format identifier consulted by the class-side dispatch path # below. Subclasses override to ``"html"`` etc.; the default @@ -4619,6 +4633,7 @@ def get_renderer( detail: DetailLevel = DetailLevel.FULL, compact: bool = False, no_timestamps: bool = False, + no_recaps: bool = False, ) -> Renderer: """Get a renderer instance for the specified format. @@ -4631,6 +4646,9 @@ def get_renderer( no_timestamps: If True, suppress per-message timestamp lines in Markdown output (issue #160). Ignored for HTML/JSON since they don't emit those lines. + no_recaps: If True, suppress ``※ recap`` (away_summary) messages at + every detail level (issue #179). Recaps are otherwise always + visible. Returns: A Renderer instance for the specified format. @@ -4658,6 +4676,7 @@ def get_renderer( raise ValueError(f"Unsupported format: {format}") renderer.detail = detail renderer.compact = compact + renderer.no_recaps = no_recaps return renderer diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md index 5fe9c45a..9ebc08e4 100644 --- a/dev-docs/application_model.md +++ b/dev-docs/application_model.md @@ -212,6 +212,13 @@ how much of the transcript renders: (designed for feeding to downstream agents, e.g. building a requirements doc). +Recaps (`AwaySummaryMessage`) are a cross-cutting exception: they are a +high-level summary of activity, so they stay visible at *every* level +(`detail_visibility = USER_ONLY`), including `user-only`. The `--no-recaps` +flag suppresses them at all levels — giving `--detail user-only --no-recaps` +for a truly user-only view, or `--detail minimal --no-recaps` to drop the +recap/agent redundancy (#179). + Filtering happens in a single *post-render* pass on `TemplateMessage`: `_ghost_template_by_detail` sets each non-visible slot in `RenderingContext.messages` to `None` ("ghosting"), keyed by the content diff --git a/dev-docs/messages.md b/dev-docs/messages.md index ba16407f..ec7e4cd7 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -708,7 +708,7 @@ class HookSummaryMessage(MessageContent): - **Condition**: `subtype: "away_summary"` - **CSS Class**: `system system-away-summary` - **Header**: `📝 Recap` (icon from `get_message_emoji`, title from `title_AwaySummaryMessage`) -- **Detail levels**: kept at FULL/HIGH (narrative content), dropped at LOW/MINIMAL/USER_ONLY (alongside bash/thinking) +- **Detail levels**: visible at EVERY level (`detail_visibility = USER_ONLY`) — a recap is itself a high-level summary of activity (#179). Suppress with `--no-recaps` (handled in `_ghost_template_by_detail`), which drops them at all levels including FULL. Claude Code emits these system entries when a session resumes after a break — narrative prose summarising recent activity. The factory strips a trailing `" (disable recaps in /config)"` UI hint when present (suffix-match, not global) so all renderers inherit the polished form. diff --git a/test/test_away_summary.py b/test/test_away_summary.py index 1fe4a55d..885c09a4 100644 --- a/test/test_away_summary.py +++ b/test/test_away_summary.py @@ -144,8 +144,10 @@ def test_recap_label_appears_once(self): class TestAwaySummaryDetailLevels: - """Detail-level filtering: recaps are content (kept at HIGH), not noise - (dropped at LOW and below — same tier as bash/thinking). + """Detail-level visibility: a recap is itself a high-level summary of + activity, so it stays visible at EVERY detail level — including user-only, + where "user said this, agent did that (just the recap)" is the wanted view + (#179). ``--no-recaps`` is the explicit opt-out, applied at all levels. The CSS rules for `.system-away-summary` ship with every page regardless of detail level, so these tests check whether a recap *message div* is @@ -158,48 +160,119 @@ class TestAwaySummaryDetailLevels: # rendered recap entries (vs. the standalone CSS rule selectors). RECAP_DIV_MARKER = "message system system-away-summary" - def _render_at(self, detail): + _ALL_LEVELS = ("full", "high", "low", "minimal", "user-only") + + def _render_at(self, detail, no_recaps: bool = False): """Render the fixture at a given detail level and return the HTML.""" from claude_code_log.html.renderer import HtmlRenderer entry = create_transcript_entry(AWAY_SUMMARY_RAW) renderer = HtmlRenderer() renderer.detail = detail + renderer.no_recaps = no_recaps return renderer.generate([entry], "Detail Test") - def test_full_keeps_recap(self): + def test_recap_visible_at_every_level(self): + """#179: recaps survive at all detail levels, full → user-only.""" from claude_code_log.models import DetailLevel - html = self._render_at(DetailLevel.FULL) - assert self.RECAP_DIV_MARKER in html - assert "project-level layout" in html + for level in self._ALL_LEVELS: + html = self._render_at(DetailLevel(level)) + assert self.RECAP_DIV_MARKER in html, f"recap dropped at {level}" + assert "project-level layout" in html, f"recap text dropped at {level}" - def test_high_keeps_recap(self): - """Monk's #1 review note: recap is narrative content, must survive - the 'detailed but cleaned' HIGH level.""" + def test_no_recaps_suppresses_at_every_level(self): + """#179: --no-recaps removes recaps regardless of detail level, + including FULL (`--detail full --no-recaps`).""" from claude_code_log.models import DetailLevel - html = self._render_at(DetailLevel.HIGH) - assert self.RECAP_DIV_MARKER in html - assert "project-level layout" in html - - def test_low_drops_recap(self): - """LOW is interaction-focused; recap (background narrative) is - dropped here alongside bash/thinking.""" + for level in self._ALL_LEVELS: + html = self._render_at(DetailLevel(level), no_recaps=True) + assert self.RECAP_DIV_MARKER not in html, ( + f"--no-recaps failed to suppress recap at {level}" + ) + + +class TestRecapVisibilityMatrix: + """Pin the issue #179 matrix end-to-end (user / agent / recap visibility). + + | user | agent | recap | how | + | ✅ | ✅ | ✅ | --detail minimal | + | ✅ | ✅ | | --detail minimal --no-recaps | + | ✅ | | ✅ | --detail user-only | + | ✅ | | | --detail user-only --no-recaps | + """ + + USER_MARK = "please-remember-the-build-command" + AGENT_MARK = "saved-it-to-memory-now" + RECAP_MARK = "project-level layout" + + def _entries(self): + user_raw = { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/app", + "sessionId": "4520f070-9e99-41bb-9400-2efd7eda4632", + "version": "2.1.110", + "type": "user", + "uuid": "u1", + "timestamp": "2026-04-16T11:50:00.000Z", + "message": {"role": "user", "content": self.USER_MARK}, + } + asst_raw = { + "parentUuid": "u1", + "isSidechain": False, + "userType": "external", + "cwd": "/app", + "sessionId": "4520f070-9e99-41bb-9400-2efd7eda4632", + "version": "2.1.110", + "type": "assistant", + "uuid": "a1", + "requestId": "r1", + "timestamp": "2026-04-16T11:51:00.000Z", + "message": { + "id": "m_a1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 5}, + "content": [{"type": "text", "text": self.AGENT_MARK}], + }, + } + recap_raw = dict(AWAY_SUMMARY_RAW, parentUuid="a1", uuid="recap-1") + return [ + create_transcript_entry(user_raw), + create_transcript_entry(asst_raw), + create_transcript_entry(recap_raw), + ] + + def _render(self, detail: str, no_recaps: bool): + from claude_code_log.html.renderer import HtmlRenderer from claude_code_log.models import DetailLevel - html = self._render_at(DetailLevel.LOW) - assert self.RECAP_DIV_MARKER not in html - assert "project-level layout" not in html + renderer = HtmlRenderer() + renderer.detail = DetailLevel(detail) + renderer.no_recaps = no_recaps + return renderer.generate(self._entries(), "Matrix") + + def _seen(self, html: str): + return ( + self.USER_MARK in html, + self.AGENT_MARK in html, + self.RECAP_MARK in html, + ) - def test_minimal_drops_recap(self): - from claude_code_log.models import DetailLevel + def test_minimal_shows_user_agent_recap(self): + assert self._seen(self._render("minimal", False)) == (True, True, True) - html = self._render_at(DetailLevel.MINIMAL) - assert self.RECAP_DIV_MARKER not in html + def test_minimal_no_recaps_shows_user_agent(self): + assert self._seen(self._render("minimal", True)) == (True, True, False) - def test_user_only_drops_recap(self): - from claude_code_log.models import DetailLevel + def test_user_only_shows_user_recap(self): + assert self._seen(self._render("user-only", False)) == (True, False, True) - html = self._render_at(DetailLevel.USER_ONLY) - assert self.RECAP_DIV_MARKER not in html + def test_user_only_no_recaps_shows_user_only(self): + assert self._seen(self._render("user-only", True)) == (True, False, False) diff --git a/test/test_detail_levels.py b/test/test_detail_levels.py index 75e2dafa..37d83472 100644 --- a/test/test_detail_levels.py +++ b/test/test_detail_levels.py @@ -999,7 +999,7 @@ def test_minimal_preserves_user_and_assistant(self, real_projects_path): all_types = set() _collect_types(root_messages, all_types) - # Should only have user/assistant text types (plus session headers) + # Should only have user/assistant text types (plus session headers). non_header_types = all_types - { "session-header", "session_header", @@ -1009,6 +1009,11 @@ def test_minimal_preserves_user_and_assistant(self, real_projects_path): "assistant", "user-steering", "user-memory", + # Recaps (away_summary) report message_type "system" and are + # now kept at every detail level (#179). They're the only + # "system" type that survives MINIMAL — all other system + # messages declare detail_visibility=FULL and are dropped here. + "system", } unexpected = non_header_types - allowed assert not unexpected, ( From c6357834f494cb524edaef981e53aec6feed24a4 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Jun 2026 18:44:03 +0200 Subject: [PATCH 2/3] Honor --no-recaps in the JSON renderer; add cross-format coverage (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #179: - Finding #1: --no-recaps was a silent no-op for --format json. The JSON renderer honors --detail (it runs the ghost pass via generate_template_messages) but didn't forward no_recaps, so recaps stayed in the JSON tree. Thread no_recaps=self.no_recaps into the json/renderer.py generate() call, matching html/markdown. - Add TestNoRecapsAllFormats exercising --no-recaps across html/md/json via the real get_renderer path (the existing matrix tests were HTML-only, which is why the JSON gap slipped). - N1: tighten the minimal real-projects assertion — assert every surviving system-typed message is specifically a recap (AwaySummaryMessage), guarding against a future system subclass leaking in. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude_code_log/json/renderer.py | 5 ++++- test/test_away_summary.py | 34 ++++++++++++++++++++++++++++++++ test/test_detail_levels.py | 21 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/claude_code_log/json/renderer.py b/claude_code_log/json/renderer.py index a2b8beca..8b6c610c 100644 --- a/claude_code_log/json/renderer.py +++ b/claude_code_log/json/renderer.py @@ -102,7 +102,10 @@ def generate( ) -> str: """Serialize the processed transcript tree to JSON.""" root_messages, session_nav, _ = generate_template_messages( - messages, session_tree=session_tree, detail=self.detail + messages, + session_tree=session_tree, + detail=self.detail, + no_recaps=self.no_recaps, ) payload: dict[str, Any] = { diff --git a/test/test_away_summary.py b/test/test_away_summary.py index 885c09a4..0899fdfb 100644 --- a/test/test_away_summary.py +++ b/test/test_away_summary.py @@ -276,3 +276,37 @@ def test_user_only_shows_user_recap(self): def test_user_only_no_recaps_shows_user_only(self): assert self._seen(self._render("user-only", True)) == (True, False, False) + + +class TestNoRecapsAllFormats: + """``--no-recaps`` must suppress recaps in EVERY message-rendering format — + html, markdown, and json. JSON honors ``--detail`` (it runs the ghost pass), + so it must honor ``--no-recaps`` too; this is the regression for the JSON + silent no-op found in the #179 review (json/renderer.py forwarded detail + but not no_recaps). The other matrix tests are HTML-only, which is why it + slipped — this exercises all three formats via the real get_renderer path.""" + + # Recap prose unique to the away_summary fixture; present in every format's + # output when the recap renders, absent when it's suppressed. + RECAP_TEXT = "project-level layout" + FORMATS = ("html", "md", "json") + + def _render(self, fmt: str, no_recaps: bool) -> str: + from claude_code_log.renderer import get_renderer + + entry = create_transcript_entry(AWAY_SUMMARY_RAW) + out = get_renderer(fmt, no_recaps=no_recaps).generate([entry], "T") + assert out is not None + return out + + def test_recap_present_without_flag(self): + for fmt in self.FORMATS: + assert self.RECAP_TEXT in self._render(fmt, no_recaps=False), ( + f"{fmt}: recap should be present without --no-recaps" + ) + + def test_no_recaps_suppresses_in_every_format(self): + for fmt in self.FORMATS: + assert self.RECAP_TEXT not in self._render(fmt, no_recaps=True), ( + f"{fmt}: --no-recaps should suppress the recap" + ) diff --git a/test/test_detail_levels.py b/test/test_detail_levels.py index 37d83472..a04fda5a 100644 --- a/test/test_detail_levels.py +++ b/test/test_detail_levels.py @@ -1020,6 +1020,11 @@ def test_minimal_preserves_user_and_assistant(self, real_projects_path): f"{jsonl_file.name}: unexpected types in minimal: {unexpected}" ) + # Tighten the "system" allowance: every surviving system-typed + # message at MINIMAL must specifically be a recap (AwaySummaryMessage). + # Guards against a future system subclass silently leaking in. + _assert_system_messages_are_recaps(root_messages, jsonl_file.name) + def test_minimal_directory_mode(self, real_projects_path, tmp_path): """Minimal mode works on a directory of JSONL files.""" # Copy a project to tmp for isolated testing @@ -1507,3 +1512,19 @@ def _collect_types(messages: list, types: set[str]) -> None: types.add(msg.type) if hasattr(msg, "children"): _collect_types(msg.children, types) + + +def _assert_system_messages_are_recaps(messages: list, label: str) -> None: + """Assert every system-typed message in the tree is a recap (#179). + + Recaps report ``message_type == "system"`` and are the only system content + kept below FULL; this verifies no other system subclass leaked in. + """ + for msg in messages: + if msg.type == "system": + cls = type(msg.content).__name__ + assert cls == "AwaySummaryMessage", ( + f"{label}: non-recap system message survived: {cls}" + ) + if hasattr(msg, "children"): + _assert_system_messages_are_recaps(msg.children, label) From a0ef2948f8c676d6a9409bfb33e2bd23b9ae6eba Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Jun 2026 19:14:24 +0200 Subject: [PATCH 3/3] Include --no-recaps in the render variant/cache identity (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #209: --no-recaps was missing from variant_suffix(), so a --no-recaps export collided with the plain one on both the output filename and the cache/path-existence key — the second invocation would be served the stale prior variant (same class as the #165 no-timestamps fix). Add no_recaps to variant_suffix() and thread it through every call site (converter combined/paginated/individual/single-session/hierarchy, and the html/markdown/json renderers). Unlike compact/no-timestamps (Markdown-only formatting), --no-recaps filters messages, so it earns a suffix slot for ALL formats, not just markdown. VARIANT_ENTRY_RE already matches the new segment. Tests: variant_suffix no_recaps matrix (html/md/json + composition) and an end-to-end test that the plain and --no-recaps exports land on distinct files. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude_code_log/converter.py | 10 ++++---- claude_code_log/html/renderer.py | 4 +++- claude_code_log/json/renderer.py | 4 +++- claude_code_log/markdown/renderer.py | 2 +- claude_code_log/utils.py | 9 +++++++ test/test_output_paths.py | 36 ++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 05ceeb93..3a615899 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -1306,7 +1306,7 @@ def _generate_paginated_html( from .html.renderer import HtmlRenderer from .utils import format_timestamp, variant_suffix as _variant_suffix - suffix = _variant_suffix(detail, compact, "html") + suffix = _variant_suffix(detail, compact, "html", no_recaps=no_recaps) # Check if page size changed - if so, invalidate all pages cached_page_size = cache_manager.get_page_size_config() @@ -1568,7 +1568,7 @@ def convert_jsonl_to( from .utils import variant_suffix as _variant_suffix - suffix = _variant_suffix(detail, compact, format, no_timestamps) + suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps) # Output destination decoupled from `input_path` (#151). Both # branches below assign to `effective_output_dir`; declare it @@ -1982,7 +1982,7 @@ def _generate_individual_session_files( from .utils import variant_suffix as _variant_suffix ext = get_file_extension(format) - suffix = _variant_suffix(detail, compact, format, no_timestamps) + 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) @@ -2229,7 +2229,7 @@ def generate_single_session_file( from .utils import variant_suffix as _variant_suffix ext = get_file_extension(format) - suffix = _variant_suffix(detail, compact, format, no_timestamps) + suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps) output_dir = input_path if output is not None: # User's explicit path wins; no suffix appended. @@ -2395,7 +2395,7 @@ def process_projects_hierarchy( # all need to use the same name. Hard-coding "combined_transcripts.html" # would make non-default --format / --detail / --compact # combinations cache-miss forever and link to the wrong file. - variant = _variant_suffix(detail, compact, output_format, no_timestamps) + variant = _variant_suffix(detail, compact, output_format, no_timestamps, no_recaps) combined_ext = get_file_extension(output_format) combined_name = f"combined_transcripts{variant}.{combined_ext}" diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 14f53992..d71490fb 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -1529,7 +1529,9 @@ def generate_session( if project_cache and project_cache.sessions: from ..utils import variant_suffix as _variant_suffix - suffix = _variant_suffix(self.detail, self.compact, "html") + suffix = _variant_suffix( + self.detail, self.compact, "html", no_recaps=self.no_recaps + ) combined_link = f"combined_transcripts{suffix}.html" except Exception: pass diff --git a/claude_code_log/json/renderer.py b/claude_code_log/json/renderer.py index 8b6c610c..36f3ca1d 100644 --- a/claude_code_log/json/renderer.py +++ b/claude_code_log/json/renderer.py @@ -148,7 +148,9 @@ def generate_session( if cache_manager is not None and not suppress_combined_link: from ..utils import variant_suffix as _variant_suffix - suffix = _variant_suffix(self.detail, self.compact, "json") + suffix = _variant_suffix( + self.detail, self.compact, "json", no_recaps=self.no_recaps + ) combined_link = f"combined_transcripts{suffix}.json" return self.generate( diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index acc5543a..f5ad27a5 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -2121,7 +2121,7 @@ def generate_session( from ..utils import variant_suffix as _variant_suffix suffix = _variant_suffix( - self.detail, self.compact, "md", self.no_timestamps + self.detail, self.compact, "md", self.no_timestamps, self.no_recaps ) combined_link = f"combined_transcripts{suffix}.md" else: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index d446dabb..b900cc91 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -50,6 +50,7 @@ def variant_suffix( compact: bool = False, format: str = "html", no_timestamps: bool = False, + no_recaps: bool = False, ) -> str: """Compute the filename infix for a given render variant. @@ -65,6 +66,14 @@ def variant_suffix( parts: list[str] = [] if detail != DetailLevel.FULL: parts.append(detail.value) + # `--no-recaps` filters *messages* out of the rendered tree, so it + # affects EVERY format (html/md/json) — unlike compact/no-timestamps + # below. It must earn a suffix slot regardless of format, else a + # `--no-recaps` export collides with the plain one on filename + cache + # key and the path-existence/cache check serves the stale variant + # (same class as the #165 no-timestamps finding; #179). + if no_recaps: + parts.append("no-recaps") # `--compact` and `--no-timestamps` are Markdown-only (merges of # same-category headings / suppression of per-message timestamp # lines). They are silent no-ops for HTML, so they don't earn a diff --git a/test/test_output_paths.py b/test/test_output_paths.py index fd1fdf6a..d94fcd09 100644 --- a/test/test_output_paths.py +++ b/test/test_output_paths.py @@ -64,6 +64,31 @@ def test_compact_markdown_only(self) -> None: assert variant_suffix(DetailLevel.FULL, True, "html") == "" assert variant_suffix(DetailLevel.LOW, True, "html") == ".low" + def test_no_recaps_all_formats(self) -> None: + # --no-recaps filters messages, so unlike compact/no-timestamps it + # earns a suffix slot for EVERY format (html/md/json), else the + # variant collides with the plain export on filename + cache key (#179). + assert variant_suffix(DetailLevel.FULL, False, "html", no_recaps=True) == ( + ".no-recaps" + ) + assert variant_suffix(DetailLevel.FULL, False, "json", no_recaps=True) == ( + ".no-recaps" + ) + assert variant_suffix(DetailLevel.FULL, False, "md", no_recaps=True) == ( + ".no-recaps" + ) + # Composes with detail (and stays ahead of the markdown-only flags). + assert ( + variant_suffix(DetailLevel.USER_ONLY, False, "html", no_recaps=True) + == ".user-only.no-recaps" + ) + assert ( + variant_suffix( + DetailLevel.USER_ONLY, True, "md", no_timestamps=True, no_recaps=True + ) + == ".user-only.no-recaps.compact.no-timestamps" + ) + def test_string_detail_accepted(self) -> None: # The CLI passes the already-normalised enum, but convenience callers # may pass the string form. @@ -238,6 +263,17 @@ def test_low_and_full_coexist(self, tmp_path: Path) -> None: assert full.exists() and low.exists() assert full != low + def test_no_recaps_and_plain_coexist(self, tmp_path: Path) -> None: + # The plain and --no-recaps exports must land on distinct files so the + # second run isn't served the first's cached/stale output (#179). + _write_session(tmp_path / "sess1.jsonl", "sess1") + plain = convert_jsonl_to("html", tmp_path, silent=True) + norecaps = convert_jsonl_to("html", tmp_path, silent=True, no_recaps=True) + assert plain.name == "combined_transcripts.html" + assert norecaps.name == "combined_transcripts.no-recaps.html" + assert plain.exists() and norecaps.exists() + assert plain != norecaps + def test_md_compact_variant(self, tmp_path: Path) -> None: _write_session(tmp_path / "sess1.jsonl", "sess1") path = convert_jsonl_to(