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/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/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..0fdd6b22 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 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 3038fa89..2ff3f2ad 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -706,9 +706,14 @@ 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 …'). + """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 @@ -739,6 +744,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 @@ -1796,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 = ( @@ -1809,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: 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..03f30acc 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, @@ -891,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). @@ -4844,6 +4858,75 @@ def _render_messages( 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, 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); + - 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). + """ + 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. + if msg is None: + continue + model = msg.meta.model + if not model: + continue + if msg.meta.is_sidechain: + agent_id = msg.meta.agent_id + if agent_id and agent_id not in agent_model: + agent_model[agent_id] = 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 + + # 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 4b56046b..14bfd057 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,29 @@ 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** 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 26266287..051962c9 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
@@ -4659,7 +4666,7 @@
- πŸ”§ Task Coverage analysis (Explore) [async #cccc333] + πŸ”§ Task Coverage analysis (Explore) [async #cccc333] claude-opus-4-7
2026-04-19 10:02:00 @@ -7083,6 +7090,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 +11049,7 @@
-
Session: eb000000
+
Session: eb000000 claude-opus-4-7
@@ -15621,6 +15635,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 +19594,7 @@
-
Session: test_ses
+
Session: test_ses claude-3-sonnet-20240229
@@ -22172,6 +22193,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 +26152,7 @@
-
Session: ef000000πŸ‘₯Team: test-coverage
+
Session: ef000000πŸ‘₯Team: test-coverage claude-opus-4-7
@@ -26299,7 +26327,7 @@
- πŸ”§ 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 @@ -26401,7 +26429,7 @@
- πŸ”§ 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 @@ -28990,6 +29018,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 +32977,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
@@ -33323,7 +33358,7 @@
-
Session: todowrit
+
Session: todowrit claude-sonnet-4
@@ -35771,6 +35806,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 +39826,7 @@
-
Session: session_
+
Session: session_ claude-3-sonnet-20240229
@@ -39896,7 +39938,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
@@ -42495,6 +42537,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 +46496,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
diff --git a/test/__snapshots__/test_snapshot_markdown.ambr b/test/__snapshots__/test_snapshot_markdown.ambr index 32a80b6d..66d23032 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…* @@ -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` @@ -114,7 +114,7 @@ - # πŸ“‹ Session `test_ses` + # πŸ“‹ Session `test_ses` β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *Hello Claude!* @@ -255,7 +255,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…* @@ -303,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` @@ -331,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` @@ -453,7 +453,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 +585,7 @@ - # πŸ“‹ Session `todowrit` + # πŸ“‹ Session `todowrit` β€” Model: `claude-sonnet-4` ## TodoWrite @@ -638,7 +638,7 @@ - # πŸ“‹ Session `session_` + # πŸ“‹ Session `session_` β€” Model: `claude-3-sonnet-20240229` ## 🀷 User: *This is from a different session file to…* @@ -660,7 +660,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 +801,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..483e3aef 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -262,6 +262,169 @@ def test_depth_badge_html_uses_spawned_depth(self) -> None: # The collapsed marker renders. assert "≑ full transcript" in html + 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() + 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 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 + 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 + ] + # 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 + from claude_code_log.markdown.renderer import MarkdownRenderer + + entries = _load_integrated() + html = generate_html(entries, "model") + # 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") + # 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 + 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, ToolUseMessage + + 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" + 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. + 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"Β· `{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_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 + (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).