From 3926a52274d2d42a634bc161d3a2ecbab2e64f64 Mon Sep 17 00:00:00 2001 From: fuleinist Date: Sat, 18 Apr 2026 08:13:28 +0800 Subject: [PATCH 1/4] Handle custom-title, agent-name, and agent-color transcript entry types These session metadata entry types are generated by Claude Code but were not recognized by the parser, causing 'Unknown transcript entry type' errors. Changes: - Make uuid and timestamp optional in PassthroughTranscriptEntry to support entry types that lack these fields - Add custom-title, agent-name, and agent-color to ENTRY_CREATORS registry - Update fallback in create_transcript_entry to handle any unknown type with a sessionId gracefully From 94f45a122865b032334d7e8ae4f36cb3dd616443 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 19 Apr 2026 10:50:53 +0200 Subject: [PATCH 2/4] Extend SILENT_SKIP_TYPES with session-metadata types (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code writes per-session state snapshots to the transcript with no uuid/parentUuid/timestamp — they are positional markers, not DAG nodes. They arrive frequently (e.g. a `permission-mode` after every mode toggle, an `agent-name`+`custom-title` pair after every /rename) and would otherwise drown the unrecognised-type warning introduced on top of #112. Four types land in the silent-skip list: - permission-mode : {permissionMode} - custom-title : {customTitle} - agent-name : {agentName} - agent-color : {agentColor} All four will become load-bearing once we propagate their state onto conversational messages in a follow-up (see #94). Dropping them silently for now is a strict improvement over the current state: either noisy warnings (reinstated warning branch on top of #112) or silent loss (main). Extend test_silent_skip with a parameterised case covering all four and repoint the unrecognised-type parametrisation onto two hypothetical future types, since the original custom-title / agent-name samples now land on the silent path. --- claude_code_log/converter.py | 8 ++++++++ test/test_dag_integration.py | 2 +- test/test_silent_skip.py | 40 ++++++++++++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index b79c7219..f9b3bafb 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -55,6 +55,14 @@ { "file-history-snapshot", # Internal file backup metadata "last-prompt", # Trailing marker written as the last line of a .jsonl + # Session metadata snapshots (positional state, no uuid/timestamp). + # Recorded whenever Claude Code writes a state checkpoint to the + # transcript; see #94 for the wider "propagate this state to + # surrounding messages" follow-up. + "permission-mode", # {permissionMode: 'acceptEdits'|...} + "custom-title", # {customTitle: } + "agent-name", # {agentName: } + "agent-color", # {agentColor: } } ) diff --git a/test/test_dag_integration.py b/test/test_dag_integration.py index bd7df62e..73f1eed8 100644 --- a/test/test_dag_integration.py +++ b/test/test_dag_integration.py @@ -1319,7 +1319,7 @@ def test_multiple_passthrough_types(self, tmp_path: Path) -> None: "p1", "s1", "2025-07-01T10:00:10.000Z", "u1", "attachment" ), _make_passthrough_entry( - "p2", "s1", "2025-07-01T10:00:20.000Z", "p1", "permission-mode" + "p2", "s1", "2025-07-01T10:00:20.000Z", "p1", "other-unknown-type" ), _make_passthrough_entry( "p3", "s1", "2025-07-01T10:00:30.000Z", "p2", "unknown-future-type" diff --git a/test/test_silent_skip.py b/test/test_silent_skip.py index b1bcbbbe..34d6e6ee 100644 --- a/test/test_silent_skip.py +++ b/test/test_silent_skip.py @@ -35,6 +35,39 @@ def test_constant_covers_issue_102(self) -> None: assert "last-prompt" in SILENT_SKIP_TYPES assert "file-history-snapshot" in SILENT_SKIP_TYPES + def test_constant_covers_session_metadata(self) -> None: + """Issue #94: session-metadata types (no uuid/timestamp) drop silently.""" + for t in ("permission-mode", "custom-title", "agent-name", "agent-color"): + assert t in SILENT_SKIP_TYPES + + @pytest.mark.parametrize( + "entry", + [ + { + "type": "permission-mode", + "permissionMode": "acceptEdits", + "sessionId": "s1", + }, + {"type": "custom-title", "customTitle": "CCL (Monk)", "sessionId": "s1"}, + {"type": "agent-name", "agentName": "CCL (Monk)", "sessionId": "s1"}, + {"type": "agent-color", "agentColor": "purple", "sessionId": "s1"}, + ], + ) + def test_session_metadata_silent( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + entry: dict[str, object], + ) -> None: + jsonl = tmp_path / "session.jsonl" + _write_jsonl(jsonl, [entry]) + + messages = load_transcript(jsonl, silent=False) + captured = capsys.readouterr() + + assert messages == [] + assert "unrecognised" not in captured.out + def test_file_history_snapshot_silent( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -129,9 +162,8 @@ class TestUnrecognisedTypesWarn: @pytest.mark.parametrize( "entry", [ - {"type": "custom-title", "customTitle": "Dave", "sessionId": "s1"}, - {"type": "agent-name", "agentName": "Dave", "sessionId": "s1"}, - {"type": "future-metadata-type", "payload": 42}, + {"type": "future-metadata-type", "payload": 42, "sessionId": "s1"}, + {"type": "another-hypothetical", "something": "value"}, ], ) def test_unknown_without_uuid_warns( @@ -154,7 +186,7 @@ def test_silent_mode_suppresses_warning( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: jsonl = tmp_path / "session.jsonl" - _write_jsonl(jsonl, [{"type": "custom-title", "customTitle": "x"}]) + _write_jsonl(jsonl, [{"type": "future-unknown-type", "payload": 1}]) messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() From 4775ec970a3bc60f134730d180ba569665a9a2e4 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 19 Apr 2026 10:57:43 +0200 Subject: [PATCH 3/4] Document session-state propagation plan for #94 follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spells out the deferred half of issue #94 — turning the now-silent session-metadata types into a visible "Assistant · CCL (Monk)" decoration on conversational messages, with colour tinting from agent-color. Covers: data shape (no uuid/timestamp, pure positional markers), file-position propagation semantics (single self-contained session file), six concrete change sites (MessageMeta fields, load_transcript state tracker, private-attr channel onto pydantic entries, create_meta forwarding, title_AssistantTextMessage decoration, CSS), cache concerns, open questions (separator, colour palette, permission-mode surfacing, session nav integration), and risks (snapshot churn, private-attr subtlety, markdown heading compactor). This PR does NOT complete #94. Wording here is deliberately free of GitHub close keywords: #94 stays open as the tracker for the state-propagation implementation documented in work/session-state-propagation.md. The PR description should also be edited to use a plain `#94` reference rather than a closing keyword so merging does not auto-close the issue. --- work/session-state-propagation.md | 233 ++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 work/session-state-propagation.md diff --git a/work/session-state-propagation.md b/work/session-state-propagation.md new file mode 100644 index 00000000..200dbc74 --- /dev/null +++ b/work/session-state-propagation.md @@ -0,0 +1,233 @@ +# Session State Propagation (issue #94 follow-up) + +## Status: Proposed — not started + +## Context + +Claude Code writes per-session state snapshots into the transcript as +standalone lines: + +```json +{"type":"custom-title","customTitle":"CCL (Monk)","sessionId":"3a97..."} +{"type":"agent-name","agentName":"CCL (Monk)","sessionId":"3a97..."} +{"type":"agent-color","agentColor":"purple","sessionId":"3a97..."} +{"type":"permission-mode","permissionMode":"acceptEdits","sessionId":"3a97..."} +``` + +PR #113's silent-skip commit makes the loader drop these without noise. +This plan covers the follow-up: actually *use* the information so the +rendered transcript surfaces the agent identity that was in effect +when each message was written. + +## Data shape + +All four types share the same shape: + +- `type` + `sessionId` + single payload field (`agentName`, `agentColor`, + `customTitle`, `permissionMode`). +- **No `uuid`, no `parentUuid`, no `timestamp`.** They are not DAG nodes. + +Real-world observations on the monk session +(`3a974998-3a21-4f1d-bc20-39874cf2f2f3.jsonl`, 900+ lines): + +- Line 1 is the first state snapshot — session files open with a + metadata header (`custom-title` first, then `agent-name`, then + `agent-color` a few lines later). +- `permission-mode` recurs heavily — one snapshot per toggle, most + with identical `acceptEdits` payload (redundant but harmless). +- `/rename` triggers a clustered update at the matching line: + `custom-title` → `agent-name` → `permission-mode`, repeated across + the conversation wherever the user renamed. + +Each session file is self-contained: a resume session starts with its +own fresh state header, so there is **no cross-file inheritance** to +worry about. + +## Target rendering + +Two decorations on conversational message titles: + +1. Assistant titles become `Assistant · CCL (Monk)` (or similar + separator) when `agentName` has a known value. +2. When `agentColor` is set, the name is wrapped in + `` so CSS can tint it. + +`permissionMode` is **not** proposed for UI surfacing in this first +pass — noisy and low signal. Keep it parsed-but-dropped unless a +future concrete need appears. + +## Propagation model + +State messages have no temporal fields, so propagation must be +**file-position-based, not DAG-based**: + +- Keep a `current_state` dict while iterating lines in + `load_transcript`. +- On a state-type line, update the relevant field in `current_state`. +- On a conversational line (anything with `uuid`+`sessionId`), record + the current `current_state` against that entry. + +This is correct even for DAG forks: the DAG is a logical view over +write order. A message's state *when it was written* is unambiguous — +the most recent state change above it in the same file. + +Cross-session case (parent session S1 resumes as S2 in a new file): +each file re-establishes its own state on line 1, so no +cross-file bridge is needed. + +## Implementation sketch + +### 1. `claude_code_log/models.py` — MessageMeta + +Add four optional fields (all default `None`): + +```python +agent_name: Optional[str] = None +agent_color: Optional[str] = None +custom_title: Optional[str] = None +permission_mode: Optional[str] = None +``` + +### 2. `claude_code_log/converter.py` — load_transcript + +Replace the `elif entry_type in SILENT_SKIP_TYPES: pass` branch with a +narrower silent-skip (keep `file-history-snapshot`, `last-prompt`) plus +a dedicated state-update branch: + +```python +SESSION_STATE_TYPES = { + "agent-name": "agent_name", + "agent-color": "agent_color", + "custom-title": "custom_title", + "permission-mode": "permission_mode", +} +SESSION_STATE_PAYLOAD = { + "agent-name": "agentName", + "agent-color": "agentColor", + "custom-title": "customTitle", + "permission-mode": "permissionMode", +} + +# At load_transcript top: +current_state: dict[str, str | None] = {f: None for f in SESSION_STATE_TYPES.values()} +entry_state: dict[str, dict[str, str | None]] = {} # uuid -> snapshot + +# In the dispatch: +elif entry_type in SESSION_STATE_TYPES: + field = SESSION_STATE_TYPES[entry_type] + value = entry_dict.get(SESSION_STATE_PAYLOAD[entry_type]) + if isinstance(value, str): + current_state[field] = value + # silently skipped from messages either way +``` + +For every conversational entry pushed to `messages`, stash a copy of +`current_state` in `entry_state[entry.uuid]`. + +Return `entry_state` as a second tuple element, or attach per-entry +via private attribute (`entry._session_state = dict(current_state)`). +The private-attr approach keeps the public signature stable at the +cost of an out-of-band channel; the tuple approach is more explicit +but ripples through every `load_transcript` call site. + +Recommendation: **private attribute**, since the state is logically +part of the entry context (like `agentId`) and callers who don't care +stay unchanged. `BaseTranscriptEntry` already tolerates it (pydantic +v2 allows non-field attributes on instances). + +### 3. `claude_code_log/factories/meta_factory.py` + +Forward the private attrs into `MessageMeta` via `getattr(..., None)`: + +```python +return MessageMeta( + ..., + agent_name=getattr(transcript, "_session_agent_name", None), + agent_color=getattr(transcript, "_session_agent_color", None), + ..., +) +``` + +### 4. Renderers — title adjustment + +`claude_code_log/renderer.py::title_AssistantTextMessage` (HTML base) +and `claude_code_log/markdown/renderer.py::title_AssistantTextMessage`: + +```python +base = "Sub-assistant" if message.meta.is_sidechain else "Assistant" +name = message.meta.agent_name +color = message.meta.agent_color +if name: + if color: + return f'{base} · {name}' + return f"{base} · {name}" +return base +``` + +HTML escape `name` (agent names can contain arbitrary characters). +Markdown renderer would emit plain text (no color span). + +### 5. CSS + +`claude_code_log/html/templates/components/message_styles.css` — map +Claude Code's color vocabulary (observed: `purple`, `orange`, plus +the standard palette) to CSS custom properties already in the theme: + +```css +.agent-color-purple { color: var(--cc-purple, #a855f7); } +.agent-color-orange { color: var(--cc-orange, #f97316); } +/* ... */ +``` + +### 6. Tests + +- `test_silent_skip.py`: add a test that checks `current_state` + bookkeeping — feed a sequence of state+conversational entries, + assert the conversational ones carry the expected snapshot on + their `MessageMeta`. +- Snapshot tests will regenerate — any transcript that contains these + state types will now show the decorated title. Manual review of + the diff will be needed to confirm intent. + +### 7. Cache concerns + +`cache.py` persists rendered HTML and pre-parsed session metadata. +Two risk points: + +- Cached parsed sessions may bypass `load_transcript` on cache hits. + Verify that the state snapshot travels through the cache (or is + stored alongside) — otherwise cache-hit renders will show bare + titles. +- Schema version bump likely required; old caches won't have the + state data. + +Audit `cache.py` for what it stores per session and extend +accordingly. + +## Open questions + +1. **Separator**: `·`, `:`, `—`, `/`? User sketched `"Assistant . CCL + (monk)"`. Prefer a visually clean glyph; `·` (U+00B7) reads well + and doesn't collide with code syntax. +2. **Color palette**: which colors does Claude Code actually emit? + Scan real transcripts for the set of `agentColor` values before + writing CSS. +3. **Permission mode rendering**: surface or not? A subtle badge on + messages whose mode ≠ `default` could be useful for auditing. Punt + unless someone asks. +4. **Nav / session card**: the session header is an obvious second + target — `agent-name` could replace or augment the session title. + Scope it out as a follow-up unless the title-level change + demonstrates clear value first. + +## Risks + +- Snapshot test churn is unavoidable and will be noisy. Coordinate + with whoever last regenerated snapshots. +- The private-attr channel on pydantic entries is clever-but-subtle; + add a short docstring so future readers don't wonder why + `entry._session_agent_name` exists. +- Markdown renderer has its own `title_AssistantTextMessage` and also + a `_last_heading_category` compact-mode tracker. Agent-name changes + should not bust heading categorisation (the tracked key is derived + from the pre-colon prefix, so this probably already works — verify). From 00b1dc80c97b5d5efafcd816851fedccbfdc54bb Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 19 Apr 2026 11:14:19 +0200 Subject: [PATCH 4/4] Switch warning text to American spelling; tighten silent-skip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small follow-ups on top of the SILENT_SKIP_TYPES work. The "unrecognised message type" warning landed in the prior PR using the British spelling, matching the pre-#97 original phrasing. #94 explicitly called out the preferred `s/recognised/recognized/`, so flip it here. Single user-facing string; the else-branch is only reached for unknown types, so unlikely to bite anyone scraping logs. CodeRabbit flagged the silent-skip test assertions — they used the weak form `"unrecognised" not in captured.out`, which only catches a specific substring and silently tolerates any other output the loader might accidentally start printing. Switch every silent-path test to call `load_transcript(..., silent=True)` and assert `captured.out == ""`; the warn-path test keeps `silent=False` and now matches the new American spelling. --- claude_code_log/converter.py | 2 +- test/test_silent_skip.py | 30 ++++++++++++++---------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index f9b3bafb..ff734dc9 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -373,7 +373,7 @@ def load_transcript( # SILENT_SKIP_TYPES once confirmed safe to drop. if not silent: print( - f"Line {line_no} of {jsonl_path}: unrecognised message type " + f"Line {line_no} of {jsonl_path}: unrecognized message type " f"{entry_type!r} - skipping" ) except json.JSONDecodeError as e: diff --git a/test/test_silent_skip.py b/test/test_silent_skip.py index 34d6e6ee..2568c357 100644 --- a/test/test_silent_skip.py +++ b/test/test_silent_skip.py @@ -62,11 +62,11 @@ def test_session_metadata_silent( jsonl = tmp_path / "session.jsonl" _write_jsonl(jsonl, [entry]) - messages = load_transcript(jsonl, silent=False) + messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() assert messages == [] - assert "unrecognised" not in captured.out + assert captured.out == "" def test_file_history_snapshot_silent( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] @@ -88,12 +88,11 @@ def test_file_history_snapshot_silent( ], ) - messages = load_transcript(jsonl, silent=False) + messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() assert messages == [] - assert "unrecognised" not in captured.out - assert "not a recognised" not in captured.out + assert captured.out == "" def test_last_prompt_silent( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] @@ -110,12 +109,11 @@ def test_last_prompt_silent( ], ) - messages = load_transcript(jsonl, silent=False) + messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() assert messages == [] - assert "unrecognised" not in captured.out - assert "last-prompt" not in captured.out + assert captured.out == "" class TestProgressStaysInDag: @@ -144,20 +142,20 @@ def test_progress_becomes_passthrough( ], ) - messages = load_transcript(jsonl, silent=False) + messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() assert len(messages) == 1 assert isinstance(messages[0], PassthroughTranscriptEntry) assert messages[0].type == "progress" assert messages[0].uuid == "p1" - assert "unrecognised" not in captured.out + assert captured.out == "" -class TestUnrecognisedTypesWarn: +class TestUnrecognizedTypesWarn: """Unknown types with no DAG fields surface a warning so we notice - new Claude Code metadata worth supporting (custom-title, agent-name, - and anything that arrives later).""" + when Claude Code ships new metadata worth supporting — anything + outside the explicit silent-skip list or the Passthrough fallback.""" @pytest.mark.parametrize( "entry", @@ -179,7 +177,7 @@ def test_unknown_without_uuid_warns( captured = capsys.readouterr() assert messages == [] - assert "unrecognised message type" in captured.out + assert "unrecognized message type" in captured.out assert repr(entry["type"]) in captured.out def test_silent_mode_suppresses_warning( @@ -214,9 +212,9 @@ def test_unknown_with_uuid_becomes_passthrough_silently( ], ) - messages = load_transcript(jsonl, silent=False) + messages = load_transcript(jsonl, silent=True) captured = capsys.readouterr() assert len(messages) == 1 assert isinstance(messages[0], PassthroughTranscriptEntry) - assert "unrecognised" not in captured.out + assert captured.out == ""