From 35a466ea13a620ca3dbfc4db8748332e01bbdf86 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 30 Jun 2026 23:06:25 +0200 Subject: [PATCH 1/5] Surface the model each agent ran on, once per agent context (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-agent transcripts can run on a different model than the trunk (e.g. a workflow fans out haiku agents under an opus main). Surface the model id so a reader can see it at a glance, without repeating it on every message: - the session header carries the trunk/main model (first non-sidechain assistant entry's model); - the first message of each sub-agent carries that sub-agent's model. A mid-course /model switch shows as its own command message, so a single first-seen value per context suffices. Plumbing: AssistantMessageModel.model -> MessageMeta.model (set in the meta factory) -> TemplateMessage.display_model, marked once per agent context by _surface_agent_models. HTML renders a muted .message-model pill in the header / on the session line; Markdown emits a `Model:` line per sub-agent and an inline `β€” Model:` suffix on the session heading. dev-docs/agents.md Β§5.4 documents the new annotation. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude_code_log/factories/meta_factory.py | 8 +- .../templates/components/message_styles.css | 7 + .../html/templates/transcript.html | 5 +- claude_code_log/markdown/renderer.py | 10 +- claude_code_log/models.py | 5 + claude_code_log/renderer.py | 47 ++++++ dev-docs/agents.md | 14 +- test/__snapshots__/test_snapshot_html.ambr | 157 +++++++++++++++++- .../__snapshots__/test_snapshot_markdown.ambr | 22 ++- test/test_nested_agents.py | 43 +++++ 10 files changed, 297 insertions(+), 21 deletions(-) diff --git a/claude_code_log/factories/meta_factory.py b/claude_code_log/factories/meta_factory.py index 3e9b7d85..0275889a 100644 --- a/claude_code_log/factories/meta_factory.py +++ b/claude_code_log/factories/meta_factory.py @@ -4,7 +4,7 @@ that is shared across all message types. """ -from ..models import BaseTranscriptEntry, MessageMeta +from ..models import AssistantTranscriptEntry, BaseTranscriptEntry, MessageMeta def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta: @@ -33,4 +33,10 @@ def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta: cwd=transcript.cwd, git_branch=transcript.gitBranch, team_name=getattr(transcript, "teamName", None), + # The model id lives on the assistant message body, not the base entry. + model=( + transcript.message.model + if isinstance(transcript, AssistantTranscriptEntry) + else None + ), ) diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index a4565678..b538347f 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -278,6 +278,13 @@ color: #888; } +/* Model id surfaced on sub-agent message headers (issue #246) */ +.message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); +} + /* Paired message styling */ .message.pair_first { margin-bottom: 0; diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 2cca16a7..2624a54e 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -129,7 +129,7 @@

πŸ” Search & Filter

card itself, not the whole subtree (issue #174 nested-DOM follow-up). #}
-
{% if message.is_branch_header %}↳ {% else %}Session: {% endif %}{{ html_content|safe }}
+
{% if message.is_branch_header %}↳ {% else %}Session: {% endif %}{{ html_content|safe }}{% if message.display_model %} {{ message.display_model }}{% endif %}
{% if message.has_children %}
{% if message.immediate_children_count == message.total_descendants_count %} @@ -188,6 +188,9 @@

πŸ” Search & Filter

{% if message.token_usage %} {{ message.token_usage }} {% endif %} + {% if message.display_model %} + {{ message.display_model }} + {% endif %}
{% if message.meta %}
{{ message.meta.uuid[:12] }}{% if message.meta.parent_uuid %} → {{ message.meta.parent_uuid[:12] }}{% endif %}
{% endif %} diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 3038fa89..af6b33a8 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -706,7 +706,7 @@ def format_SessionHeaderMessage( return f'' def title_SessionHeaderMessage( - self, content: SessionHeaderMessage, _: TemplateMessage + self, content: SessionHeaderMessage, message: TemplateMessage ) -> str: """Title β†’ 'πŸ“‹ Session `abc12345`: summary β€” Team: `t`' (or '🌿 Branch …'). @@ -739,6 +739,9 @@ def title_SessionHeaderMessage( title = f"πŸ“‹ Session `{session_short}`: {content.summary}" else: title = f"πŸ“‹ Session `{session_short}`" + # Main agent model, surfaced once on the session header (issue #246). + if message.display_model: + title = f"{title} β€” Model: {_inline_code(message.display_model)}" if content.team_name: # Boundary hygiene: a malformed transcript could in theory # carry a backtick in teamName. CommonMark code spans don't @@ -2092,6 +2095,11 @@ def _render_message(self, msg: TemplateMessage, level: int) -> str: if ts_line: parts.append(ts_line) + # Sub-agent model, surfaced once on its first message (issue #246). + # Outside the suppress-heading guard so it survives compact mode. + if msg.display_model and not is_session_header: + parts.append(f"Model: {_inline_code(msg.display_model)}") + # Format content (if not already output above) if content: parts.append(content) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 795cb107..31f59f6f 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -412,6 +412,11 @@ class MessageMeta: cwd: str = "" git_branch: Optional[str] = None team_name: Optional[str] = None # Active team name (teammates feature) + # The model id the assistant entry ran on (AssistantMessageModel.model), + # e.g. ``claude-opus-4-8``. Only assistant entries carry it; None elsewhere. + # Surfaced on sub-agent message headers so a reader can see which model a + # spawned agent used (issue #246). + model: Optional[str] = None @classmethod def empty(cls, uuid: str = "") -> "MessageMeta": diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index e163a437..00a7a7ea 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -284,6 +284,14 @@ def __init__( # spawn that produced no transcript at all (#213 visual layer). self.spawns_collapsed_transcript: bool = False + # Model id to surface in this message's header (issue #246). Set by + # _surface_agent_models once per agent context β€” on the session header + # (the trunk/main model) and on the first message of each sub-agent + # (the model that sub-agent ran on) β€” so the id shows once rather than + # on every message. None elsewhere. The raw per-entry value lives on + # ``meta.model``; this is the render-once decision derived from it. + self.display_model: Optional[str] = None + # Per-render annotations populated by the HTML renderer's tree walk # (HtmlRenderer._annotate_tree_for_render). The recursive template # macro reads these instead of receiving a flat (msg, title, html, @@ -4841,9 +4849,48 @@ def _render_messages( tool_msg.render_session_id = effective_session ctx.register(tool_msg) + _surface_agent_models(ctx) return ctx +def _surface_agent_models(ctx: RenderingContext) -> None: + """Mark which messages should display their model id (issue #246). + + Surfaced once per agent context rather than on every message: + - the **session header** carries the trunk/main agent's model (the first + non-sidechain assistant model seen for that session); + - the **first message of each sub-agent** carries that sub-agent's model. + + A mid-course ``/model`` switch surfaces as its own command message, so a + single first-seen value per context is enough. ``meta.model`` is only set + on assistant entries, so a truthy value already filters to assistant-origin + chunks (text or tool_use). + """ + seen_subagents: set[str] = set() + seen_sessions: set[str] = set() + for msg in ctx.messages: + # ctx.messages may carry None slots (ghosted entries); skip them. + if msg is None: + continue + model = msg.meta.model + if not model: + continue + if msg.meta.is_sidechain and msg.meta.agent_id: + if msg.meta.agent_id not in seen_subagents: + seen_subagents.add(msg.meta.agent_id) + msg.display_model = model + else: + session_id = msg.meta.session_id + if session_id and session_id not in seen_sessions: + seen_sessions.add(session_id) + header_index = ctx.session_first_message.get(session_id) + header = ( + ctx.messages[header_index] if header_index is not None else None + ) + if header is not None: + header.display_model = model + + # -- Project Index Generation ------------------------------------------------- diff --git a/dev-docs/agents.md b/dev-docs/agents.md index 4b56046b..d4f1f2de 100644 --- a/dev-docs/agents.md +++ b/dev-docs/agents.md @@ -318,7 +318,7 @@ transcript: 0.5em) so very deep chains (79 levels seen in the wild) stay on-screen β€” depth is carried by the badge + colour, not the indent. -Two card-level annotations complete the layer: +Three card-level annotations complete the layer: - **Depth badge** (`_agent_depth_badge`, `html/renderer.py`): a "Depth N" pill on a spawn card showing the depth of the sub-agent it *opens* @@ -332,6 +332,18 @@ Two card-level annotations complete the layer: shown is its whole transcript, distinct from a spawn that produced none. Nested-only (`agent_depth >= 1`); trunk-level direct sub-agents keep their pre-#213 rendering. +- **Model id** (`TemplateMessage.display_model`, set by + `_surface_agent_models` in `renderer.py`; issue #246): the model an + agent ran on, surfaced **once per agent context** rather than on + every message β€” on the session header (the trunk/main model, from the + first non-sidechain assistant entry) and on the first message of each + sub-agent (that sub-agent's model). A mid-course `/model` switch + surfaces as its own command message, so a single first-seen value per + context suffices. The raw per-entry value is `MessageMeta.model` + (only assistant entries carry it); `display_model` is the render-once + decision derived from it. HTML renders a muted `.message-model` pill + in the header (beside the timestamp / on the session line); Markdown + emits a `Model: ` `` `…` `` line / inline `β€” Model:` heading suffix. ### 5.5 Fixture diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 26266287..bdcc09d6 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -647,6 +647,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -4599,7 +4606,7 @@
-
Session: eb000000
+
Session: eb000000 claude-opus-4-7
@@ -4630,6 +4637,7 @@ 2026-04-19 10:01:00
+
11111111-000
@@ -4665,6 +4673,7 @@ 2026-04-19 10:02:00
+
22222222-000 → 11111111-000
@@ -4687,6 +4696,7 @@ 2026-04-19 10:03:00 +
11111111-000 → 22222222-000
@@ -4725,6 +4735,9 @@ 2026-04-19 10:04:00 + + claude-opus-4-7 +
cccccccc-000 → cccccccc-000
@@ -4752,6 +4765,7 @@ 2026-04-19 10:04:00 +
22222222-000 → 11111111-000
@@ -4773,6 +4787,7 @@ 2026-04-19 10:05:00 +
11111111-000 → 22222222-000
@@ -4794,6 +4809,7 @@ 2026-04-19 10:06:00 +
11111111-000 → 11111111-000
@@ -4815,6 +4831,7 @@ 2026-04-19 10:07:00 +
22222222-000 → 11111111-000
@@ -7083,6 +7100,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -11035,7 +11059,7 @@
-
Session: eb000000
+
Session: eb000000 claude-opus-4-7
@@ -11066,6 +11090,7 @@ 2026-04-19 10:01:00
+
11111111-000
@@ -11097,6 +11122,7 @@ 2026-04-19 10:02:00 +
22222222-000 → 11111111-000
@@ -11119,6 +11145,7 @@ 2026-04-19 10:03:00 +
11111111-000 → 22222222-000
@@ -11150,6 +11177,7 @@ 2026-04-19 10:07:00 +
22222222-000 → 11111111-000
@@ -15621,6 +15649,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -19573,7 +19608,7 @@
-
Session: test_ses
+
Session: test_ses claude-3-sonnet-20240229
@@ -19604,6 +19639,7 @@ 2025-07-03 15:50:07
+
msg_001
@@ -19637,6 +19673,7 @@ Input: 25 | Output: 120 +
msg_002
@@ -19683,6 +19720,7 @@ 2025-07-03 15:54:07 +
msg_003
@@ -19714,6 +19752,7 @@ 2025-07-03 15:56:07 +
msg_004
@@ -19735,6 +19774,7 @@ 2025-07-03 15:58:07 +
msg_005
@@ -19758,6 +19798,7 @@ Input: 78 | Output: 95 +
msg_006
@@ -19797,6 +19838,7 @@ 2025-07-03 16:02:07 +
msg_007
@@ -19828,6 +19870,7 @@ 2025-07-03 16:04:07 +
msg_008
@@ -19849,6 +19892,7 @@ 2025-07-03 16:06:07 +
msg_009
@@ -19874,6 +19918,7 @@ Input: 45 | Output: 110 +
msg_010
@@ -19909,6 +19954,7 @@ 2025-07-03 16:10:07 +
msg_011
@@ -22172,6 +22218,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -26124,7 +26177,7 @@
-
Session: ef000000πŸ‘₯Team: test-coverage
+
Session: ef000000πŸ‘₯Team: test-coverage claude-opus-4-7
@@ -26155,6 +26208,7 @@ 2026-04-19 10:01:00
+
11111111-000
@@ -26190,6 +26244,7 @@ 2026-04-19 10:02:00 +
22222222-000 → 11111111-000
@@ -26211,6 +26266,7 @@ 2026-04-19 10:03:00 +
11111111-000 → 22222222-000
@@ -26232,6 +26288,7 @@ 2026-04-19 10:04:00 +
22222222-000 → 11111111-000
@@ -26257,6 +26314,7 @@ 2026-04-19 10:06:00 +
22222222-000 → 11111111-000
@@ -26282,6 +26340,7 @@ 2026-04-19 10:08:00 +
22222222-000 → 11111111-000
@@ -26305,6 +26364,7 @@ 2026-04-19 10:10:00 +
22222222-000 → 11111111-000
@@ -26327,6 +26387,7 @@ 2026-04-19 10:11:00 +
11111111-000 → 22222222-000
@@ -26358,6 +26419,9 @@ 2026-04-19 10:02:00 + + claude-opus-4-7 +
aaaaaaaa-000 → aaaaaaaa-000
@@ -26380,6 +26444,7 @@ 2026-04-19 10:03:00 +
aaaaaaaa-000 → aaaaaaaa-000
@@ -26407,6 +26472,7 @@ 2026-04-19 10:12:00 +
22222222-000 → 11111111-000
@@ -26429,6 +26495,7 @@ 2026-04-19 10:13:00 +
11111111-000 → 22222222-000
@@ -26460,6 +26527,7 @@ 2026-04-19 10:01:00 +
bbbbbbbb-000
@@ -26482,6 +26550,9 @@ 2026-04-19 10:02:00 + + claude-opus-4-7 +
bbbbbbbb-000 → bbbbbbbb-000
@@ -26504,6 +26575,7 @@ 2026-04-19 10:03:00 +
bbbbbbbb-000 → bbbbbbbb-000
@@ -26536,6 +26608,7 @@ 2026-04-19 10:14:00 +
11111111-000 → 11111111-000
@@ -26562,6 +26635,7 @@ 2026-04-19 10:15:00 +
11111111-000 → 11111111-000
@@ -26595,6 +26669,7 @@ 2026-04-19 10:16:00 +
22222222-000 → 11111111-000
@@ -26616,6 +26691,7 @@ 2026-04-19 10:17:00 +
11111111-000 → 22222222-000
@@ -26637,6 +26713,7 @@ 2026-04-19 10:18:00 +
22222222-000 → 11111111-000
@@ -26659,6 +26736,7 @@ 2026-04-19 10:19:00 +
11111111-000 → 22222222-000
@@ -26680,6 +26758,7 @@ 2026-04-19 10:20:00 +
22222222-000 → 11111111-000
@@ -26701,6 +26780,7 @@ 2026-04-19 10:21:00 +
11111111-000 → 22222222-000
@@ -26722,6 +26802,7 @@ 2026-04-19 10:22:00 +
22222222-000 → 11111111-000
@@ -28990,6 +29071,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -32942,7 +33030,7 @@
-
Session: Tested various edge cases including markdown formatting, long text, tool errors, system messages, command outputs, special characters and emojis. All message types render correctly in the transcript viewer. β€’ edge_cas
+
Session: Tested various edge cases including markdown formatting, long text, tool errors, system messages, command outputs, special characters and emojis. All message types render correctly in the transcript viewer. β€’ edge_cas claude-3-sonnet-20240229
@@ -32973,6 +33061,7 @@ 2025-06-14 11:00:00
+
edge_001
@@ -33006,6 +33095,7 @@ Input: 35 | Output: 145 +
edge_002
@@ -33071,6 +33161,7 @@ 2025-06-14 11:01:00 +
edge_003
@@ -33102,6 +33193,7 @@ 2025-06-14 11:01:30 +
edge_004
@@ -33128,6 +33220,7 @@ 2025-06-14 11:01:31 +
edge_005
@@ -33154,6 +33247,7 @@ 2025-06-14 11:02:10 +
edge_007
@@ -33177,6 +33271,7 @@ 2025-06-14 11:02:20 +
edge_008
@@ -33218,6 +33313,7 @@ Input: 85 | Output: 180 +
edge_009
@@ -33249,6 +33345,7 @@ 2025-06-14 11:03:00 +
edge_009
@@ -33280,6 +33377,7 @@ 2025-06-14 11:03:30 +
edge_011
@@ -33302,6 +33400,7 @@ 2025-06-14 11:03:01 +
edge_010
@@ -33323,7 +33422,7 @@
-
Session: todowrit
+
Session: todowrit claude-sonnet-4
@@ -33350,6 +33449,7 @@ 2025-06-14 10:02:00
+
assistant_00 → assistant_00
@@ -35771,6 +35871,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -39784,7 +39891,7 @@
-
Session: session_
+
Session: session_ claude-3-sonnet-20240229
@@ -39815,6 +39922,7 @@ 2025-07-03 15:50:07
+
session_b_00
@@ -39848,6 +39956,7 @@ Input: 20 | Output: 35 +
session_b_00
@@ -39875,6 +39984,7 @@ 2025-07-03 15:54:07 +
session_b_00
@@ -39896,7 +40006,7 @@
-
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€’ test_ses
+
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€’ test_ses claude-3-sonnet-20240229
@@ -39927,6 +40037,7 @@ 2025-07-03 15:50:07
+
msg_001
@@ -39960,6 +40071,7 @@ Input: 25 | Output: 120 +
msg_002
@@ -40006,6 +40118,7 @@ 2025-07-03 15:54:07 +
msg_003
@@ -40037,6 +40150,7 @@ 2025-07-03 15:56:07 +
msg_004
@@ -40058,6 +40172,7 @@ 2025-07-03 15:58:07 +
msg_005
@@ -40081,6 +40196,7 @@ Input: 78 | Output: 95 +
msg_006
@@ -40120,6 +40236,7 @@ 2025-07-03 16:02:07 +
msg_007
@@ -40151,6 +40268,7 @@ 2025-07-03 16:04:07 +
msg_008
@@ -40172,6 +40290,7 @@ 2025-07-03 16:06:07 +
msg_009
@@ -40197,6 +40316,7 @@ Input: 45 | Output: 110 +
msg_010
@@ -40232,6 +40352,7 @@ 2025-07-03 16:10:07 +
msg_011
@@ -42495,6 +42616,13 @@ color: #888; } + /* Model id surfaced on sub-agent message headers (issue #246) */ + .message-model { + font-size: 0.75em; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace); + color: var(--text-secondary, #888); + } + /* Paired message styling */ .message.pair_first { margin-bottom: 0; @@ -46447,7 +46575,7 @@
-
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€’ test_ses
+
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€’ test_ses claude-3-sonnet-20240229
@@ -46478,6 +46606,7 @@ 2025-07-03 15:50:07
+
msg_001
@@ -46511,6 +46640,7 @@ Input: 25 | Output: 120 +
msg_002
@@ -46557,6 +46687,7 @@ 2025-07-03 15:54:07 +
msg_003
@@ -46588,6 +46719,7 @@ 2025-07-03 15:56:07 +
msg_004
@@ -46609,6 +46741,7 @@ 2025-07-03 15:58:07 +
msg_005
@@ -46632,6 +46765,7 @@ Input: 78 | Output: 95 +
msg_006
@@ -46671,6 +46805,7 @@ 2025-07-03 16:02:07 +
msg_007
@@ -46702,6 +46837,7 @@ 2025-07-03 16:04:07 +
msg_008
@@ -46723,6 +46859,7 @@ 2025-07-03 16:06:07 +
msg_009
@@ -46748,6 +46885,7 @@ Input: 45 | Output: 110 +
msg_010
@@ -46783,6 +46921,7 @@ 2025-07-03 16:10:07 +
msg_011
diff --git a/test/__snapshots__/test_snapshot_markdown.ambr b/test/__snapshots__/test_snapshot_markdown.ambr index 32a80b6d..40e85555 100644 --- a/test/__snapshots__/test_snapshot_markdown.ambr +++ b/test/__snapshots__/test_snapshot_markdown.ambr @@ -13,7 +13,7 @@ - # πŸ“‹ Session `eb000000` + # πŸ“‹ Session `eb000000` β€” Model: `claude-opus-4-7` ## 🀷 User: *Spawn a coverage analysis agent in the background…* @@ -54,6 +54,8 @@ `10:04:00` + Model: `claude-opus-4-7` + > Reading the source files now... ### πŸ” TaskOutput `#cccc333` @@ -114,7 +116,7 @@ - # πŸ“‹ Session `test_ses` + # πŸ“‹ Session `test_ses` β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *Hello Claude!* @@ -255,7 +257,7 @@ - # πŸ“‹ Session `ef000000` β€” Team: `test-coverage` + # πŸ“‹ Session `ef000000` β€” Model: `claude-opus-4-7` β€” Team: `test-coverage` ## 🀷 User: *Start a test-coverage team and dispatch two…* @@ -323,6 +325,8 @@ `10:02:00` + Model: `claude-opus-4-7` + > Starting relay coverage work. #### πŸ”— Sub-assistant: *Relay module coverage is now 96%.* @@ -359,6 +363,8 @@ `10:02:00` + Model: `claude-opus-4-7` + > Starting server coverage work. #### πŸ”— Sub-assistant: *Server module coverage is now 88%.* @@ -453,7 +459,7 @@ - # πŸ“‹ Session `edge_cas`: Tested various edge cases including markdown formatting, long text, tool errors, system messages, command outputs, special characters and emojis. All message types render correctly in the transcript viewer. + # πŸ“‹ Session `edge_cas`: Tested various edge cases including markdown formatting, long text, tool errors, system messages, command outputs, special characters and emojis. All message types render correctly in the transcript viewer. β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *Here's a message with some \*\*markdown\*\* formatting…* @@ -585,7 +591,7 @@ - # πŸ“‹ Session `todowrit` + # πŸ“‹ Session `todowrit` β€” Model: `claude-sonnet-4` ## TodoWrite @@ -638,7 +644,7 @@ - # πŸ“‹ Session `session_` + # πŸ“‹ Session `session_` β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *This is from a different session file to…* @@ -660,7 +666,7 @@ - # πŸ“‹ Session `test_ses`: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. + # πŸ“‹ Session `test_ses`: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *Hello Claude!* @@ -801,7 +807,7 @@ - # πŸ“‹ Session `test_ses`: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. + # πŸ“‹ Session `test_ses`: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *Hello Claude!* diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index 84c825a0..d7deb1fa 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -262,6 +262,49 @@ def test_depth_badge_html_uses_spawned_depth(self) -> None: # The collapsed marker renders. assert "≑ full transcript" in html + def test_model_surfaced_once_per_subagent(self) -> None: + """Issue #246: each sub-agent's model id is marked for display on + exactly one of its messages (its first), not on every message.""" + from collections import Counter + + msgs = self._ctx_messages() + sub = [m for m in msgs if m.display_model and m.meta.is_sidechain] + per_agent = Counter(m.meta.agent_id for m in sub) + # Every sub-agent surfaces its model once, never twice. + assert set(per_agent) == set(ALL_AGENTS) + assert all(count == 1 for count in per_agent.values()) + # The fixture's sub-agents all run on haiku. + assert all(m.display_model == "claude-haiku-4-5-20251001" for m in sub) + + def test_trunk_model_surfaced_only_on_session_header(self) -> None: + """Issue #246: the trunk/main model shows on the session header, and + no trunk body message carries the pill.""" + msgs = self._ctx_messages() + headers = [m for m in msgs if m.display_model and m.is_session_header] + assert len(headers) == 1 + assert headers[0].display_model == "claude-haiku-4-5-20251001" + trunk_body = [ + m + for m in msgs + if m.display_model and not m.meta.is_sidechain and not m.is_session_header + ] + assert trunk_body == [] + + def test_model_renders_in_html_and_markdown(self) -> None: + from claude_code_log.html.renderer import generate_html + from claude_code_log.markdown.renderer import MarkdownRenderer + + entries = _load_integrated() + html = generate_html(entries, "model") + assert "class='message-model'" in html + assert "claude-haiku-4-5-20251001" in html + + md = MarkdownRenderer().generate(entries, "model") + # First message of a sub-agent gets a standalone model line. + assert "Model: `claude-haiku-4-5-20251001`" in md + # The session header carries the main model inline on its heading. + assert "β€” Model: `claude-haiku-4-5-20251001`" in md + def test_new_sidecar_invalidates_cached_trunk(self, tmp_path: Path) -> None: """Sidecar inputs are part of the cache key (PR #218 review). From ce6e9679791b3842966ed1d89cbdb6a27ad53eef Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 30 Jun 2026 23:16:27 +0200 Subject: [PATCH 2/5] test: pin per-context model attribution with distinct trunk model (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested_agents fixture runs the trunk and every sub-agent on the same haiku id, so the once-per-context tests assert the same string in two places and can't distinguish correct attribution from cross- contamination β€” which is exactly #246's headline. Retarget only the trunk's assistant entries to a distinct model (opus) in-test and pin that the session header shows IT while each sub-agent keeps haiku, with no leakage either way, across both renderers. (monk review #3959.) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/test_nested_agents.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index d7deb1fa..436f77a1 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -305,6 +305,47 @@ def test_model_renders_in_html_and_markdown(self) -> None: # The session header carries the main model inline on its heading. assert "β€” Model: `claude-haiku-4-5-20251001`" in md + def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: + """Issue #246's raison d'Γͺtre: trunk and sub-agents can run on + DIFFERENT models. The fixture runs everything on haiku, so the + once-per-context tests above can't tell correct attribution from + cross-contamination. Retarget only the trunk's assistant entries to + a distinct model and pin that the session header shows IT while each + sub-agent keeps its own β€” no leakage either way (monk #3959).""" + from claude_code_log.html.renderer import generate_html + from claude_code_log.markdown.renderer import MarkdownRenderer + from claude_code_log.models import AssistantTranscriptEntry + + trunk_model = "claude-opus-4-8" + sub_model = "claude-haiku-4-5-20251001" + entries = _load_integrated() + retargeted = 0 + for entry in entries: + if isinstance(entry, AssistantTranscriptEntry) and not entry.isSidechain: + entry.message.model = trunk_model + retargeted += 1 + assert retargeted, "fixture should carry trunk assistant entries" + + _roots, _nav, ctx = generate_template_messages(entries) + msgs = [m for m in ctx.messages if m is not None] + headers = [ + m.display_model for m in msgs if m.display_model and m.is_session_header + ] + assert headers == [trunk_model], "trunk model must land on the session header" + subs = [m for m in msgs if m.display_model and m.meta.is_sidechain] + assert subs and all(m.display_model == sub_model for m in subs), ( + "sub-agents must keep their own model, not inherit the trunk's" + ) + + # Both renderers attribute correctly end-to-end. + html = generate_html(entries, "diff") + assert trunk_model in html and sub_model in html + md = MarkdownRenderer().generate(entries, "diff") + assert f"β€” Model: `{trunk_model}`" in md # session header only + assert f"Model: `{sub_model}`" in md # a sub-agent + # The trunk model never appears as a standalone sub-agent Model line. + assert f"\nModel: `{trunk_model}`" not in md + def test_new_sidecar_invalidates_cached_trunk(self, tmp_path: Path) -> None: """Sidecar inputs are part of the cache key (PR #218 review). From 446cdce6c6948cabdd4a714c5f7bec48cdc9b0b4 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 30 Jun 2026 23:36:35 +0200 Subject: [PATCH 3/5] Address CodeRabbit review on #252 (model-surfacing polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _surface_agent_models: a sidechain message without an agent_id no longer falls through to the trunk-header branch (it could otherwise overwrite the session header's main model with a sub-agent's). Now an explicit is_sidechain branch leaves such an entry unattributed. - transcript.html: gate the session-header model pill on `not is_branch_header`, matching the Markdown renderer's structural guard so branch and trunk headers stay in parity across formats. - title_SessionHeaderMessage docstring: document the new `β€” Model: \`…\`` segment in the title example. Pinned by test_sidechain_without_agent_id_never_leaks_into_header (rogue sidechain entry registered before the trunk model β€” the old fall-through would have set the header to the rogue model). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../html/templates/transcript.html | 2 +- claude_code_log/markdown/renderer.py | 7 +++- claude_code_log/renderer.py | 11 +++++-- test/test_nested_agents.py | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 2624a54e..9ea5c833 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -129,7 +129,7 @@

πŸ” Search & Filter

card itself, not the whole subtree (issue #174 nested-DOM follow-up). #}
-
{% if message.is_branch_header %}↳ {% else %}Session: {% endif %}{{ html_content|safe }}{% if message.display_model %} {{ message.display_model }}{% endif %}
+
{% if message.is_branch_header %}↳ {% else %}Session: {% endif %}{{ html_content|safe }}{% if message.display_model and not message.is_branch_header %} {{ message.display_model }}{% endif %}
{% if message.has_children %}
{% if message.immediate_children_count == message.total_descendants_count %} diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index af6b33a8..ea75a119 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -708,7 +708,12 @@ def format_SessionHeaderMessage( def title_SessionHeaderMessage( self, content: SessionHeaderMessage, message: TemplateMessage ) -> str: - """Title β†’ 'πŸ“‹ Session `abc12345`: summary β€” Team: `t`' (or '🌿 Branch …'). + """Title β†’ 'πŸ“‹ Session `abc12345`: summary β€” Model: `m` β€” Team: `t`' + (or '🌿 Branch …'). + + The ``β€” Model: `…``` segment (issue #246) carries the trunk/main + model and appears, before any Team suffix, only on non-branch + session headers that have a ``display_model`` set. Branch session headers surface the ``Branch β€’ β€’ `` shape that the renderer's ``_branch_label`` helper composes for diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 00a7a7ea..dbc38d2b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -4875,11 +4875,16 @@ def _surface_agent_models(ctx: RenderingContext) -> None: model = msg.meta.model if not model: continue - if msg.meta.is_sidechain and msg.meta.agent_id: - if msg.meta.agent_id not in seen_subagents: - seen_subagents.add(msg.meta.agent_id) + if msg.meta.is_sidechain: + # Sub-agent context: attribute to its agent_id. A sidechain entry + # that somehow lacks an agent_id is left unattributed rather than + # leaking into the trunk header below. + agent_id = msg.meta.agent_id + if agent_id and agent_id not in seen_subagents: + seen_subagents.add(agent_id) msg.display_model = model else: + # Trunk context: the main model goes on the session header. session_id = msg.meta.session_id if session_id and session_id not in seen_sessions: seen_sessions.add(session_id) diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index 436f77a1..fc5edc01 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -346,6 +346,38 @@ def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: # The trunk model never appears as a standalone sub-agent Model line. assert f"\nModel: `{trunk_model}`" not in md + def test_sidechain_without_agent_id_never_leaks_into_header(self) -> None: + """A sidechain message lacking an agent_id stays unattributed β€” it + must not fall through and overwrite the session header's trunk model + (CodeRabbit, PR #252). Registered BEFORE the trunk message so the old + ``else`` fall-through would have set the header to the rogue model.""" + from claude_code_log.models import MessageMeta, SystemMessage + from claude_code_log.renderer import ( + RenderingContext, + _surface_agent_models, + ) + + def _sys(uuid: str, **meta_kwargs: object) -> TemplateMessage: + meta = MessageMeta(session_id="s", timestamp="t", uuid=uuid, **meta_kwargs) # type: ignore[arg-type] + return TemplateMessage(SystemMessage(meta=meta, level="info", text="")) + + ctx = RenderingContext() + header = _sys("h") + ctx.session_first_message["s"] = ctx.register(header) + # Rogue sidechain entry: is_sidechain but no agent_id, distinct model. + rogue = _sys( + "r", is_sidechain=True, agent_id=None, model="claude-haiku-4-5-20251001" + ) + ctx.register(rogue) + # Real trunk model, registered after the rogue. + trunk = _sys("a", model="claude-opus-4-8") + ctx.register(trunk) + + _surface_agent_models(ctx) + + assert header.display_model == "claude-opus-4-8" + assert rogue.display_model is None + def test_new_sidecar_invalidates_cached_trunk(self, tmp_path: Path) -> None: """Sidecar inputs are part of the cache key (PR #218 review). From 98ac214e4c7b4ac942f68d6689fb47c4e22066a6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 1 Jul 2026 11:39:05 +0200 Subject: [PATCH 4/5] Move the sub-agent model onto its spawn card, visible when folded (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-message pill sat on the sub-agent's first message, which is hidden whenever the sub-agent transcript is folded β€” i.e. exactly where a reader scanning a spawn ("πŸ”§ Agent … (Explore)") looks for it. Move it to the spawn card itself: the Task/Agent tool_use that opens the sub-agent, which stays on screen when the transcript collapses and already carries the depth badge, so the two annotations sit together. - `_surface_agent_models` now runs late in `generate_template_messages` (after pairing + async linking, so a spawn card can reach its paired tool_result and hoisted `minted_agent_id`). Pass 1 maps agentβ†’model and stamps the session header; pass 2 stamps each `TaskInput` spawn card, resolving the spawned agent via `minted_agent_id` (async #90), else the paired result's `spawned_agent_id` (#213) or `agent_id`. Gated to `TaskInput` so a regular tool inside a sub-agent never picks up the model. - HTML: `_agent_model_badge` appends the pill to the spawn-card title beside the depth badge (not suppressed at depth 1). The generic header-info pill is removed. - Markdown: the model is appended as ` Β· `model`` to the Task title; the per-message `Model:` line is removed. Session-header trunk model unchanged in both renderers. Verified on the nested (sync), async (#90), and a real multi-agent transcript: model on every spawn card, none on sub-agent body messages. dev-docs/agents.md Β§5.4 updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude_code_log/html/renderer.py | 21 +++- .../html/templates/transcript.html | 3 - claude_code_log/markdown/renderer.py | 21 ++-- claude_code_log/renderer.py | 53 +++++++--- dev-docs/agents.md | 31 ++++-- test/__snapshots__/test_snapshot_html.ambr | 96 +------------------ .../__snapshots__/test_snapshot_markdown.ambr | 12 +-- test/test_nested_agents.py | 62 +++++++----- 8 files changed, 138 insertions(+), 161 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 6e7df37f..7911508e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -1032,7 +1032,11 @@ def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str: if input.run_in_background else "" ) - suffix = async_hint + self._agent_depth_badge(message) + suffix = ( + async_hint + + self._agent_depth_badge(message) + + self._agent_model_badge(message) + ) if input.description and input.subagent_type: escaped_desc = escape_html(input.description) return ( @@ -1069,6 +1073,21 @@ def _agent_depth_badge(self, message: TemplateMessage) -> str: f"Depth {spawned_depth}" ) + def _agent_model_badge(self, message: TemplateMessage) -> str: + """Model badge for a spawn card (#246): the model the spawned + sub-agent ran on, stamped on the always-visible Task/Agent launch + card by ``_surface_agent_models`` (``display_model``). Shown for + every spawn depth β€” unlike the depth badge, it isn't suppressed at + depth 1, since the top-level Explore/Task spawn is the common case a + reader wants to see. Empty when no model was resolved (e.g. an + interrupted spawn whose sub-agent produced no assistant turn).""" + if not message.display_model: + return "" + return ( + f" " + f"{escape_html(message.display_model)}" + ) + def _async_id_suffix( self, minted_id: Optional[str], diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 9ea5c833..0fdd6b22 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -188,9 +188,6 @@

πŸ” Search & Filter

{% if message.token_usage %} {{ message.token_usage }} {% endif %} - {% if message.display_model %} - {{ message.display_model }} - {% endif %}
{% if message.meta %}
{{ message.meta.uuid[:12] }}{% if message.meta.parent_uuid %} → {{ message.meta.parent_uuid[:12] }}{% endif %}
{% endif %} diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index ea75a119..2ff3f2ad 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -1804,12 +1804,14 @@ def title_GrepInput(self, input: GrepInput, _: TemplateMessage) -> str: base = f"πŸ”Ž Grep `{input.pattern}`" return f"{base} in `{input.path}`" if input.path else base - def title_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str: - """Title β†’ 'πŸ€– Task (subagent): *description* [async #]'. + def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str: + """Title β†’ 'πŸ€– Task (subagent): *description* [async #] Β· `model`'. ``[async]`` muted hint when ``run_in_background=True``; once the launch confirmation has been parsed, the minted ``#`` is appended (PR #158 follow-up, parallels the Bash spawn shape). + The trailing `` Β· `model` `` is the model the spawned sub-agent ran + on (issue #246), stamped on the spawn card by ``_surface_agent_models``. """ subagent = f" ({input.subagent_type})" if input.subagent_type else "" async_hint = ( @@ -1817,9 +1819,15 @@ def title_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str: if input.run_in_background else "" ) + model_hint = ( + f" Β· {_inline_code(message.display_model)}" if message.display_model else "" + ) if desc := input.description: - return f"πŸ€– Task{subagent}: *{self._escape_stars(desc)}*{async_hint}" - return f"πŸ€– Task{subagent}{async_hint}" + return ( + f"πŸ€– Task{subagent}: *{self._escape_stars(desc)}*" + f"{async_hint}{model_hint}" + ) + return f"πŸ€– Task{subagent}{async_hint}{model_hint}" @staticmethod def _async_id_hint(minted_id: Optional[str]) -> str: @@ -2100,11 +2108,6 @@ def _render_message(self, msg: TemplateMessage, level: int) -> str: if ts_line: parts.append(ts_line) - # Sub-agent model, surfaced once on its first message (issue #246). - # Outside the suppress-heading guard so it survives compact mode. - if msg.display_model and not is_session_header: - parts.append(f"Model: {_inline_code(msg.display_model)}") - # Format content (if not already output above) if content: parts.append(content) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index dbc38d2b..03f30acc 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -899,6 +899,12 @@ def generate_template_messages( with log_timing("Link async notifications", t_start): _link_async_notifications(ctx, detail) + # Surface the model each agent ran on (issue #246). Runs here β€” after + # pairing and async linking β€” so a spawn card can reach its paired + # tool_result and its hoisted ``minted_agent_id`` to resolve the sub-agent. + with log_timing("Surface agent models", t_start): + _surface_agent_models(ctx) + # Link parsed dynamic-workflow runs to their Workflow tool_use by taskId # (#174 PR3) so the formatter can render snapshot-first meta (and step 3 # can splice the phase/agent tree). @@ -4849,24 +4855,33 @@ def _render_messages( tool_msg.render_session_id = effective_session ctx.register(tool_msg) - _surface_agent_models(ctx) return ctx def _surface_agent_models(ctx: RenderingContext) -> None: """Mark which messages should display their model id (issue #246). - Surfaced once per agent context rather than on every message: + Surfaced once per agent context rather than on every message, and on a + node that stays visible when the agent's transcript is folded: - the **session header** carries the trunk/main agent's model (the first non-sidechain assistant model seen for that session); - - the **first message of each sub-agent** carries that sub-agent's model. + - each **spawn card** (the Task/Agent ``tool_use`` that opens a sub-agent) + carries the model that sub-agent ran on β€” looked up from the sub-agent's + own first assistant model via ``spawned_agent_id``. The spawn card sits + at the *parent's* depth and stays on screen even when the sub-agent's + transcript collapses, so the model shows exactly where the reader looks + for it (issue #246 follow-up). A mid-course ``/model`` switch surfaces as its own command message, so a single first-seen value per context is enough. ``meta.model`` is only set on assistant entries, so a truthy value already filters to assistant-origin chunks (text or tool_use). """ - seen_subagents: set[str] = set() + from .models import TaskInput, ToolUseMessage + + # Pass 1: collect each sub-agent's model (first-seen) and stamp the + # trunk/main model onto each session header. + agent_model: dict[str, str] = {} seen_sessions: set[str] = set() for msg in ctx.messages: # ctx.messages may carry None slots (ghosted entries); skip them. @@ -4876,15 +4891,10 @@ def _surface_agent_models(ctx: RenderingContext) -> None: if not model: continue if msg.meta.is_sidechain: - # Sub-agent context: attribute to its agent_id. A sidechain entry - # that somehow lacks an agent_id is left unattributed rather than - # leaking into the trunk header below. agent_id = msg.meta.agent_id - if agent_id and agent_id not in seen_subagents: - seen_subagents.add(agent_id) - msg.display_model = model + if agent_id and agent_id not in agent_model: + agent_model[agent_id] = model else: - # Trunk context: the main model goes on the session header. session_id = msg.meta.session_id if session_id and session_id not in seen_sessions: seen_sessions.add(session_id) @@ -4895,6 +4905,27 @@ def _surface_agent_models(ctx: RenderingContext) -> None: if header is not None: header.display_model = model + # Pass 2: stamp each sub-agent's model onto its spawn card β€” the Task/Agent + # ``tool_use`` that opens it, which carries the depth badge and stays visible + # when the sub-agent's transcript folds. Gated strictly to ``TaskInput`` so a + # regular tool inside a sub-agent never picks up the model. The spawned + # agent id is resolved from whichever linkage the transcript carries: the + # async ``minted_agent_id`` on the input, else the paired tool_result's + # ``spawned_agent_id`` (#213) or ``agent_id`` (async #90 ``toolUseResult``). + for msg in ctx.messages: + if msg is None or not isinstance(msg.content, ToolUseMessage): + continue + task_input = msg.content.input + if not isinstance(task_input, TaskInput): + continue + spawned = task_input.minted_agent_id + if not spawned and msg.pair_last is not None: + result = ctx.messages[msg.pair_last] + if result is not None: + spawned = result.meta.spawned_agent_id or result.meta.agent_id + if spawned and spawned in agent_model: + msg.display_model = agent_model[spawned] + # -- Project Index Generation ------------------------------------------------- diff --git a/dev-docs/agents.md b/dev-docs/agents.md index d4f1f2de..14bfd057 100644 --- a/dev-docs/agents.md +++ b/dev-docs/agents.md @@ -334,16 +334,27 @@ Three card-level annotations complete the layer: sub-agents keep their pre-#213 rendering. - **Model id** (`TemplateMessage.display_model`, set by `_surface_agent_models` in `renderer.py`; issue #246): the model an - agent ran on, surfaced **once per agent context** rather than on - every message β€” on the session header (the trunk/main model, from the - first non-sidechain assistant entry) and on the first message of each - sub-agent (that sub-agent's model). A mid-course `/model` switch - surfaces as its own command message, so a single first-seen value per - context suffices. The raw per-entry value is `MessageMeta.model` - (only assistant entries carry it); `display_model` is the render-once - decision derived from it. HTML renders a muted `.message-model` pill - in the header (beside the timestamp / on the session line); Markdown - emits a `Model: ` `` `…` `` line / inline `β€” Model:` heading suffix. + agent ran on, surfaced **once per agent context** and on a node that + stays visible when the agent's transcript folds β€” on the session + header (the trunk/main model, from the first non-sidechain assistant + entry) and on each **spawn card** (the Task/Agent `tool_use` that + opens a sub-agent). The sub-agent's model comes from its own first + assistant entry, joined back to the spawn card via `tool_use_id` + (`spawned_agent_id` lands on the tool_result; the two-pass helper maps + agentβ†’model, then stamps the paired tool_use β€” pairing indices aren't + assigned yet, so it uses the id join, not `pair_first`). The spawn + card sits at the parent's depth and stays on screen even when the + sub-agent collapses, so the model shows where the reader looks β€” and + co-locates with the depth badge. A mid-course `/model` switch surfaces + as its own command message, so a single first-seen value per context + suffices. The raw per-entry value is `MessageMeta.model` (only + assistant entries carry it); `display_model` is the render-once + decision. HTML renders a muted `.message-model` pill β€” in the spawn + card title (`_agent_model_badge`, beside the depth badge) and inline + on the session-header line; Markdown appends `` Β· `…` `` to the Task + title and an inline `β€” Model:` on the session heading. Unlike the + depth badge it is **not** suppressed at depth 1 (the top-level + Explore/Task spawn is the common case a reader wants). ### 5.5 Fixture diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index bdcc09d6..051962c9 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4637,7 +4637,6 @@ 2026-04-19 10:01:00
-
11111111-000
@@ -4667,13 +4666,12 @@
- πŸ”§ Task Coverage analysis (Explore) [async #cccc333] + πŸ”§ Task Coverage analysis (Explore) [async #cccc333] claude-opus-4-7
2026-04-19 10:02:00
-
22222222-000 → 11111111-000
@@ -4696,7 +4694,6 @@ 2026-04-19 10:03:00
-
11111111-000 → 22222222-000
@@ -4735,9 +4732,6 @@ 2026-04-19 10:04:00 - - claude-opus-4-7 -
cccccccc-000 → cccccccc-000
@@ -4765,7 +4759,6 @@ 2026-04-19 10:04:00 -
22222222-000 → 11111111-000
@@ -4787,7 +4780,6 @@ 2026-04-19 10:05:00 -
11111111-000 → 22222222-000
@@ -4809,7 +4801,6 @@ 2026-04-19 10:06:00 -
11111111-000 → 11111111-000
@@ -4831,7 +4822,6 @@ 2026-04-19 10:07:00 -
22222222-000 → 11111111-000
@@ -11090,7 +11080,6 @@ 2026-04-19 10:01:00 -
11111111-000
@@ -11122,7 +11111,6 @@ 2026-04-19 10:02:00 -
22222222-000 → 11111111-000
@@ -11145,7 +11133,6 @@ 2026-04-19 10:03:00 -
11111111-000 → 22222222-000
@@ -11177,7 +11164,6 @@ 2026-04-19 10:07:00 -
22222222-000 → 11111111-000
@@ -19639,7 +19625,6 @@ 2025-07-03 15:50:07 -
msg_001
@@ -19673,7 +19658,6 @@ Input: 25 | Output: 120 -
msg_002
@@ -19720,7 +19704,6 @@ 2025-07-03 15:54:07 -
msg_003
@@ -19752,7 +19735,6 @@ 2025-07-03 15:56:07 -
msg_004
@@ -19774,7 +19756,6 @@ 2025-07-03 15:58:07 -
msg_005
@@ -19798,7 +19779,6 @@ Input: 78 | Output: 95 -
msg_006
@@ -19838,7 +19818,6 @@ 2025-07-03 16:02:07 -
msg_007
@@ -19870,7 +19849,6 @@ 2025-07-03 16:04:07 -
msg_008
@@ -19892,7 +19870,6 @@ 2025-07-03 16:06:07 -
msg_009
@@ -19918,7 +19895,6 @@ Input: 45 | Output: 110 -
msg_010
@@ -19954,7 +19930,6 @@ 2025-07-03 16:10:07 -
msg_011
@@ -26208,7 +26183,6 @@ 2026-04-19 10:01:00 -
11111111-000
@@ -26244,7 +26218,6 @@ 2026-04-19 10:02:00 -
22222222-000 → 11111111-000
@@ -26266,7 +26239,6 @@ 2026-04-19 10:03:00 -
11111111-000 → 22222222-000
@@ -26288,7 +26260,6 @@ 2026-04-19 10:04:00 -
22222222-000 → 11111111-000
@@ -26314,7 +26285,6 @@ 2026-04-19 10:06:00 -
22222222-000 → 11111111-000
@@ -26340,7 +26310,6 @@ 2026-04-19 10:08:00 -
22222222-000 → 11111111-000
@@ -26358,13 +26327,12 @@
- πŸ”§ Task Run alice's test work (general-purpose) + πŸ”§ Task Run alice's test work (general-purpose) claude-opus-4-7
2026-04-19 10:10:00
-
22222222-000 → 11111111-000
@@ -26387,7 +26355,6 @@ 2026-04-19 10:11:00
-
11111111-000 → 22222222-000
@@ -26419,9 +26386,6 @@ 2026-04-19 10:02:00 - - claude-opus-4-7 -
aaaaaaaa-000 → aaaaaaaa-000
@@ -26444,7 +26408,6 @@ 2026-04-19 10:03:00 -
aaaaaaaa-000 → aaaaaaaa-000
@@ -26466,13 +26429,12 @@
- πŸ”§ Task Run bob's test work (general-purpose) + πŸ”§ Task Run bob's test work (general-purpose) claude-opus-4-7
2026-04-19 10:12:00
-
22222222-000 → 11111111-000
@@ -26495,7 +26457,6 @@ 2026-04-19 10:13:00
-
11111111-000 → 22222222-000
@@ -26527,7 +26488,6 @@ 2026-04-19 10:01:00 -
bbbbbbbb-000
@@ -26550,9 +26510,6 @@ 2026-04-19 10:02:00 - - claude-opus-4-7 -
bbbbbbbb-000 → bbbbbbbb-000
@@ -26575,7 +26532,6 @@ 2026-04-19 10:03:00 -
bbbbbbbb-000 → bbbbbbbb-000
@@ -26608,7 +26564,6 @@ 2026-04-19 10:14:00 -
11111111-000 → 11111111-000
@@ -26635,7 +26590,6 @@ 2026-04-19 10:15:00 -
11111111-000 → 11111111-000
@@ -26669,7 +26623,6 @@ 2026-04-19 10:16:00 -
22222222-000 → 11111111-000
@@ -26691,7 +26644,6 @@ 2026-04-19 10:17:00 -
11111111-000 → 22222222-000
@@ -26713,7 +26665,6 @@ 2026-04-19 10:18:00 -
22222222-000 → 11111111-000
@@ -26736,7 +26687,6 @@ 2026-04-19 10:19:00 -
11111111-000 → 22222222-000
@@ -26758,7 +26708,6 @@ 2026-04-19 10:20:00 -
22222222-000 → 11111111-000
@@ -26780,7 +26729,6 @@ 2026-04-19 10:21:00 -
11111111-000 → 22222222-000
@@ -26802,7 +26750,6 @@ 2026-04-19 10:22:00 -
22222222-000 → 11111111-000
@@ -33061,7 +33008,6 @@ 2025-06-14 11:00:00 -
edge_001
@@ -33095,7 +33041,6 @@ Input: 35 | Output: 145 -
edge_002
@@ -33161,7 +33106,6 @@ 2025-06-14 11:01:00 -
edge_003
@@ -33193,7 +33137,6 @@ 2025-06-14 11:01:30 -
edge_004
@@ -33220,7 +33163,6 @@ 2025-06-14 11:01:31 -
edge_005
@@ -33247,7 +33189,6 @@ 2025-06-14 11:02:10 -
edge_007
@@ -33271,7 +33212,6 @@ 2025-06-14 11:02:20 -
edge_008
@@ -33313,7 +33253,6 @@ Input: 85 | Output: 180 -
edge_009
@@ -33345,7 +33284,6 @@ 2025-06-14 11:03:00 -
edge_009
@@ -33377,7 +33315,6 @@ 2025-06-14 11:03:30 -
edge_011
@@ -33400,7 +33337,6 @@ 2025-06-14 11:03:01 -
edge_010
@@ -33449,7 +33385,6 @@ 2025-06-14 10:02:00 -
assistant_00 → assistant_00
@@ -39922,7 +39857,6 @@ 2025-07-03 15:50:07 -
session_b_00
@@ -39956,7 +39890,6 @@ Input: 20 | Output: 35 -
session_b_00
@@ -39984,7 +39917,6 @@ 2025-07-03 15:54:07 -
session_b_00
@@ -40037,7 +39969,6 @@ 2025-07-03 15:50:07 -
msg_001
@@ -40071,7 +40002,6 @@ Input: 25 | Output: 120 -
msg_002
@@ -40118,7 +40048,6 @@ 2025-07-03 15:54:07 -
msg_003
@@ -40150,7 +40079,6 @@ 2025-07-03 15:56:07 -
msg_004
@@ -40172,7 +40100,6 @@ 2025-07-03 15:58:07 -
msg_005
@@ -40196,7 +40123,6 @@ Input: 78 | Output: 95 -
msg_006
@@ -40236,7 +40162,6 @@ 2025-07-03 16:02:07 -
msg_007
@@ -40268,7 +40193,6 @@ 2025-07-03 16:04:07 -
msg_008
@@ -40290,7 +40214,6 @@ 2025-07-03 16:06:07 -
msg_009
@@ -40316,7 +40239,6 @@ Input: 45 | Output: 110 -
msg_010
@@ -40352,7 +40274,6 @@ 2025-07-03 16:10:07 -
msg_011
@@ -46606,7 +46527,6 @@ 2025-07-03 15:50:07 -
msg_001
@@ -46640,7 +46560,6 @@ Input: 25 | Output: 120 -
msg_002
@@ -46687,7 +46606,6 @@ 2025-07-03 15:54:07 -
msg_003
@@ -46719,7 +46637,6 @@ 2025-07-03 15:56:07 -
msg_004
@@ -46741,7 +46658,6 @@ 2025-07-03 15:58:07 -
msg_005
@@ -46765,7 +46681,6 @@ Input: 78 | Output: 95 -
msg_006
@@ -46805,7 +46720,6 @@ 2025-07-03 16:02:07 -
msg_007
@@ -46837,7 +46751,6 @@ 2025-07-03 16:04:07 -
msg_008
@@ -46859,7 +46772,6 @@ 2025-07-03 16:06:07 -
msg_009
@@ -46885,7 +46797,6 @@ Input: 45 | Output: 110 -
msg_010
@@ -46921,7 +46832,6 @@ 2025-07-03 16:10:07 -
msg_011
diff --git a/test/__snapshots__/test_snapshot_markdown.ambr b/test/__snapshots__/test_snapshot_markdown.ambr index 40e85555..66d23032 100644 --- a/test/__snapshots__/test_snapshot_markdown.ambr +++ b/test/__snapshots__/test_snapshot_markdown.ambr @@ -21,7 +21,7 @@ Spawn a coverage analysis agent in the background and wait for the result. - ### πŸ€– Task (Explore): *Coverage analysis* *[async `#cccc333`]* + ### πŸ€– Task (Explore): *Coverage analysis* *[async `#cccc333`]* Β· `claude-opus-4-7` `10:02:00` @@ -54,8 +54,6 @@ `10:04:00` - Model: `claude-opus-4-7` - > Reading the source files now... ### πŸ” TaskOutput `#cccc333` @@ -305,7 +303,7 @@ βœ“ Updated `#1` β€” `owner`, `status` - ### πŸ€– Task (general-purpose): *Run alice's test work* + ### πŸ€– Task (general-purpose): *Run alice's test work* Β· `claude-opus-4-7` `10:10:00` @@ -325,8 +323,6 @@ `10:02:00` - Model: `claude-opus-4-7` - > Starting relay coverage work. #### πŸ”— Sub-assistant: *Relay module coverage is now 96%.* @@ -335,7 +331,7 @@ > Relay module coverage is now 96%. - ### πŸ€– Task (general-purpose): *Run bob's test work* + ### πŸ€– Task (general-purpose): *Run bob's test work* Β· `claude-opus-4-7` `10:12:00` @@ -363,8 +359,6 @@ `10:02:00` - Model: `claude-opus-4-7` - > Starting server coverage work. #### πŸ”— Sub-assistant: *Server module coverage is now 88%.* diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index fc5edc01..7a9445ac 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -262,19 +262,24 @@ def test_depth_badge_html_uses_spawned_depth(self) -> None: # The collapsed marker renders. assert "≑ full transcript" in html - def test_model_surfaced_once_per_subagent(self) -> None: - """Issue #246: each sub-agent's model id is marked for display on - exactly one of its messages (its first), not on every message.""" - from collections import Counter + def test_model_surfaced_on_spawn_cards(self) -> None: + """Issue #246: a sub-agent's model is stamped on its spawn card (the + Task/Agent tool_use that opens it) so it stays visible when the + sub-agent transcript is folded β€” never on the sub-agent's own body + messages. The only other carrier is the session header (trunk model).""" + from claude_code_log.models import ToolUseMessage msgs = self._ctx_messages() - sub = [m for m in msgs if m.display_model and m.meta.is_sidechain] - per_agent = Counter(m.meta.agent_id for m in sub) - # Every sub-agent surfaces its model once, never twice. - assert set(per_agent) == set(ALL_AGENTS) - assert all(count == 1 for count in per_agent.values()) + pill_bearers = [m for m in msgs if m.display_model] + # Every pill lives on a spawn card (tool_use) or the session header. + assert all( + isinstance(m.content, ToolUseMessage) or m.is_session_header + for m in pill_bearers + ) + spawn_pills = [m for m in pill_bearers if isinstance(m.content, ToolUseMessage)] + assert spawn_pills, "sub-agent spawns should carry the model" # The fixture's sub-agents all run on haiku. - assert all(m.display_model == "claude-haiku-4-5-20251001" for m in sub) + assert all(m.display_model == "claude-haiku-4-5-20251001" for m in spawn_pills) def test_trunk_model_surfaced_only_on_session_header(self) -> None: """Issue #246: the trunk/main model shows on the session header, and @@ -288,7 +293,10 @@ def test_trunk_model_surfaced_only_on_session_header(self) -> None: for m in msgs if m.display_model and not m.meta.is_sidechain and not m.is_session_header ] - assert trunk_body == [] + # Trunk-level spawn cards are the only non-header trunk carriers. + from claude_code_log.models import ToolUseMessage + + assert all(isinstance(m.content, ToolUseMessage) for m in trunk_body) def test_model_renders_in_html_and_markdown(self) -> None: from claude_code_log.html.renderer import generate_html @@ -296,25 +304,27 @@ def test_model_renders_in_html_and_markdown(self) -> None: entries = _load_integrated() html = generate_html(entries, "model") - assert "class='message-model'" in html + # Spawn-card model badge (visible even when the sub-agent is folded). + assert "title='Model this sub-agent ran on'" in html assert "claude-haiku-4-5-20251001" in html md = MarkdownRenderer().generate(entries, "model") - # First message of a sub-agent gets a standalone model line. - assert "Model: `claude-haiku-4-5-20251001`" in md + # Spawn card (Task title) carries the model inline. + assert "Β· `claude-haiku-4-5-20251001`" in md # The session header carries the main model inline on its heading. assert "β€” Model: `claude-haiku-4-5-20251001`" in md def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: """Issue #246's raison d'Γͺtre: trunk and sub-agents can run on DIFFERENT models. The fixture runs everything on haiku, so the - once-per-context tests above can't tell correct attribution from - cross-contamination. Retarget only the trunk's assistant entries to - a distinct model and pin that the session header shows IT while each - sub-agent keeps its own β€” no leakage either way (monk #3959).""" + placement tests above can't tell correct attribution from cross- + contamination. Retarget only the trunk's assistant entries to a + distinct model and pin that the session header shows IT while each + spawn card shows its sub-agent's model β€” no leakage either way + (monk #3959).""" from claude_code_log.html.renderer import generate_html from claude_code_log.markdown.renderer import MarkdownRenderer - from claude_code_log.models import AssistantTranscriptEntry + from claude_code_log.models import AssistantTranscriptEntry, ToolUseMessage trunk_model = "claude-opus-4-8" sub_model = "claude-haiku-4-5-20251001" @@ -332,9 +342,11 @@ def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: m.display_model for m in msgs if m.display_model and m.is_session_header ] assert headers == [trunk_model], "trunk model must land on the session header" - subs = [m for m in msgs if m.display_model and m.meta.is_sidechain] - assert subs and all(m.display_model == sub_model for m in subs), ( - "sub-agents must keep their own model, not inherit the trunk's" + spawn_pills = [ + m for m in msgs if m.display_model and isinstance(m.content, ToolUseMessage) + ] + assert spawn_pills and all(m.display_model == sub_model for m in spawn_pills), ( + "spawn cards must show their sub-agent's model, not the trunk's" ) # Both renderers attribute correctly end-to-end. @@ -342,9 +354,9 @@ def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: assert trunk_model in html and sub_model in html md = MarkdownRenderer().generate(entries, "diff") assert f"β€” Model: `{trunk_model}`" in md # session header only - assert f"Model: `{sub_model}`" in md # a sub-agent - # The trunk model never appears as a standalone sub-agent Model line. - assert f"\nModel: `{trunk_model}`" not in md + assert f"Β· `{sub_model}`" in md # a spawn card + # The trunk model never appears as a spawn-card model suffix. + assert f"Β· `{trunk_model}`" not in md def test_sidechain_without_agent_id_never_leaks_into_header(self) -> None: """A sidechain message lacking an agent_id stays unattributed β€” it From a2f187363391acab6b88655eca85d61d022ccf34 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 1 Jul 2026 11:47:26 +0200 Subject: [PATCH 5/5] test: pin per-agent spawn-card attribution across the nested chain (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-haiku fixture can't catch a spawn card resolving to the wrong sub-agent (parent/child collapse in the nschain1β†’2β†’3 chain, or an async-fallback mixup). Give every sub-agent a distinct model and assert the bijection: each spawn card carries exactly its own spawned child's model β€” no duplicates, no leakage, chain each distinct. (monk #3990, who proved this out-of-band; committing it keeps it in CI.) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/test_nested_agents.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index 7a9445ac..483e3aef 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -358,6 +358,41 @@ def test_model_attribution_distinguishes_trunk_from_subagents(self) -> None: # The trunk model never appears as a spawn-card model suffix. assert f"Β· `{trunk_model}`" not in md + def test_each_spawn_card_shows_its_own_subagents_model(self) -> None: + """Issue #246: with the all-haiku fixture, the placement tests can't + catch a spawn card resolving to the WRONG sub-agent (parent/child + collapse in the nested chain, or an async-fallback mixup). Give every + sub-agent a distinct model and pin the bijection: each spawn card + carries exactly its own spawned child's model β€” no duplicates, no + leakage β€” including the 3-deep chain (monk #3990).""" + from claude_code_log.models import AssistantTranscriptEntry, ToolUseMessage + + entries = _load_integrated() + for entry in entries: + if ( + isinstance(entry, AssistantTranscriptEntry) + and entry.isSidechain + and entry.agentId + ): + entry.message.model = f"model-{entry.agentId}" + + _roots, _nav, ctx = generate_template_messages(entries) + msgs = [m for m in ctx.messages if m is not None] + spawn_models = [ + m.display_model + for m in msgs + if m.display_model and isinstance(m.content, ToolUseMessage) + ] + # Bijection: one distinct model per spawn card, each its own sub-agent's. + assert len(spawn_models) == len(set(spawn_models)), ( + "a spawn card reused another agent's model (parent/child collapse)" + ) + assert set(spawn_models) == {f"model-{agent}" for agent in ALL_AGENTS} + # The nested chain must each carry their OWN distinct model. + assert {f"model-{CHAIN1}", f"model-{CHAIN2}", f"model-{CHAIN3}"} <= set( + spawn_models + ) + def test_sidechain_without_agent_id_never_leaks_into_header(self) -> None: """A sidechain message lacking an agent_id stays unattributed β€” it must not fall through and overwrite the session header's trunk model