Skip to content

Surface the model each agent ran on, once per agent context (#246)#252

Open
cboos wants to merge 5 commits into
mainfrom
dev/subagent-model-meta
Open

Surface the model each agent ran on, once per agent context (#246)#252
cboos wants to merge 5 commits into
mainfrom
dev/subagent-model-meta

Conversation

@cboos

@cboos cboos commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #246.

Sub-agent transcripts can run on a different model than the trunk — e.g. a workflow fans out haiku agents under an opus main, or a Task(model="sonnet") spawn. This surfaces the model id each agent ran on so a reader can see it at a glance, once per agent context rather than repeated on every message:

  • the session header carries the trunk/main model (from the first non-sidechain assistant entry);
  • the first message of each sub-agent carries that sub-agent's model.

A mid-course /model switch already shows as its own command message, so a single first-seen value per context is enough.

How it looks

  • HTML — a muted mono .message-model pill in the message header (beside the timestamp) and inline on the session-header line, mirroring the existing workflow-agent-model style.
  • Markdown — a Model: `…` line per sub-agent and an inline — Model: suffix on the session heading.

Plumbing

AssistantMessageModel.modelMessageMeta.model (set in the meta factory, guarded to assistant entries) → TemplateMessage.display_model, the render-once decision made by the new _surface_agent_models pass at the end of _render_messages (keys sub-agents by agent_id, the trunk by session_id → its session header; guards ghosted None slots).

The model id is transcript-derived (attacker-controllable via a crafted transcript, post-#245), so both sinks neutralise it: HTML autoescapes via Jinja (no |safe), Markdown fences it via _inline_code.

Tests

test/test_nested_agents.py (TestNestedVisualLayer): once-per-sub-agent marking, trunk-model-on-session-header-only, HTML+Markdown rendering, and — pinning #246's core promise — a distinct-model case (retarget the trunk to opus, sub-agents stay haiku) asserting correct per-context attribution with no cross-contamination.

dev-docs/agents.md §5.4 documents the new annotation. just ci green (incl. tui+browser); snapshots updated.

Reviewed by monk (approved; 3 watch-points + XSS verified). Design driven in-session by @daaain.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added “Model id” badges across session headers and sub-agent spawn/task cards in both HTML and Markdown.
    • Model display is rendered once per agent context (trunk model on the session header; sub-agent model on spawn/task entries).
  • Bug Fixes

    • Improved model attribution so trunk and sub-agent models don’t duplicate or leak into the wrong parts of the nested-agent view.
  • Documentation

    • Updated nested-agent visuals documentation with the new “Model id” annotation and render-once behavior.
  • Tests

    • Expanded nested-agent coverage and refreshed HTML/Markdown snapshots for model badge rendering.

cboos and others added 2 commits June 30, 2026 23:06
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds assistant model metadata, surfaces it once per agent context, and renders it in HTML and Markdown session and task headers. Documentation, tests, and snapshots were updated to reflect the new model labels.

Changes

Sub-agent and trunk model surfacing

Layer / File(s) Summary
Capture model id in MessageMeta
claude_code_log/models.py, claude_code_log/factories/meta_factory.py
Adds MessageMeta.model and fills it from AssistantTranscriptEntry.message.model in create_meta().
Surface display_model per agent context
claude_code_log/renderer.py
Adds TemplateMessage.display_model and _surface_agent_models(ctx), which assigns model ids to session headers and spawn cards.
Render model badges in HTML and Markdown
claude_code_log/html/templates/transcript.html, claude_code_log/html/templates/components/message_styles.css, claude_code_log/html/renderer.py, claude_code_log/markdown/renderer.py
Renders .message-model badges in HTML and Model:/— Model: text in Markdown when display_model is set.
Documentation, tests, and snapshots
dev-docs/agents.md, test/test_nested_agents.py, test/__snapshots__/test_snapshot_html.ambr, test/__snapshots__/test_snapshot_markdown.ambr
Updates nested-agent docs, adds model attribution tests, and refreshes stored outputs.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: surfacing each agent's model once per context.
Linked Issues check ✅ Passed The PR fulfills #246 by surfacing sub-agent model usage in transcripts and rendering it once per agent context.
Out of Scope Changes check ✅ Passed The code changes, tests, docs, and styling all support the model-attribution feature and do not add unrelated scope.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/subagent-model-meta

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
claude_code_log/markdown/renderer.py (1)

708-744: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Docstring example doesn't reflect the new Model suffix.

The docstring still documents the title shape as '📋 Session \abc12345`: summary — Team: `t`' with no mention of the new "— Model: \...`" segment that's now inserted before Team for non-branch sessions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude_code_log/markdown/renderer.py` around lines 708 - 744, Update the
docstring for title_SessionHeaderMessage so its example matches the current
SessionHeaderMessage title format, including the new “— Model: `...`” segment
for non-branch sessions before the Team suffix. Keep the branch example accurate
too, and make sure the documented shapes reflect the logic in
title_SessionHeaderMessage and the message.display_model branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@claude_code_log/html/templates/transcript.html`:
- Line 132: The session header rendering is inconsistent between HTML and
Markdown because the shared `div.header` in `transcript.html` always shows
`message.display_model`, while `markdown/renderer.py`’s
`title_SessionHeaderMessage` only adds the model suffix on the trunk path.
Update the branch-header logic so `message.is_branch_header` and
`message.display_model` are handled consistently in both renderers, using the
same rule for whether `.message-model` or the “— Model:” suffix should appear.
Check the `header` block in `transcript.html` and `title_SessionHeaderMessage`
in `markdown/renderer.py` together so branch session headers and trunk headers
stay in parity.

In `@claude_code_log/renderer.py`:
- Around line 4856-4892: The _surface_agent_models helper is letting sidechain
messages without msg.meta.agent_id fall through into the trunk/session header
path, which can overwrite the main header’s display_model with a sub-agent
model. Update _surface_agent_models so any msg.meta.is_sidechain entry is
handled only in the sidechain branch, and skip the header-update logic when
agent_id is missing. Use msg.meta.agent_id, msg.meta.is_sidechain,
msg.meta.session_id, and ctx.session_first_message to keep trunk/main models on
the session header and sub-agent models only on the first sidechain message.

---

Nitpick comments:
In `@claude_code_log/markdown/renderer.py`:
- Around line 708-744: Update the docstring for title_SessionHeaderMessage so
its example matches the current SessionHeaderMessage title format, including the
new “— Model: `...`” segment for non-branch sessions before the Team suffix.
Keep the branch example accurate too, and make sure the documented shapes
reflect the logic in title_SessionHeaderMessage and the message.display_model
branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6454b951-4a48-44af-bfa1-97b91f415d25

📥 Commits

Reviewing files that changed from the base of the PR and between 8c974bf and ce6e967.

📒 Files selected for processing (10)
  • claude_code_log/factories/meta_factory.py
  • claude_code_log/html/templates/components/message_styles.css
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/agents.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_nested_agents.py

Comment thread claude_code_log/html/templates/transcript.html Outdated
Comment thread claude_code_log/renderer.py
cboos and others added 3 commits June 30, 2026 23:36
- _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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
)

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) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/test_nested_agents.py`:
- Around line 370-390: The expected spawn-model assertion in
test_nested_agents.py should not include INTR because the fixture never creates
an AssistantTranscriptEntry for nsintr01 and _surface_agent_models only assigns
display_model for spawned agents that actually exist. Update the spawn-model
expectation in generate_template_messages-related assertions so it derives from
the agents present in entries (or explicitly excludes nsintr01) while keeping
the bijection check on spawn_models intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 38ffa4eb-a5ae-4375-a8c9-788244259059

📥 Commits

Reviewing files that changed from the base of the PR and between 98ac214 and a2f1873.

📒 Files selected for processing (1)
  • test/test_nested_agents.py

Comment on lines +370 to +390
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the interrupted-spawn fixture agent has any assistant entries with an agentId,
# which would determine whether it ever contributes a model.
fd -e jsonl -e json . test | xargs rg -l 'isSidechain' 2>/dev/null | head -20

Repository: daaain/claude-code-log

Length of output: 1499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the test file and inspect the relevant section.
ast-grep outline test/test_nested_agents.py --view expanded >/tmp/nested_outline.txt
sed -n '1,120p' /tmp/nested_outline.txt

echo
echo "=== Relevant test section ==="
sed -n '340,410p' test/test_nested_agents.py

echo
echo "=== Find ALL_AGENTS and INTR definitions ==="
rg -n 'ALL_AGENTS|INTR|test_each_spawn_card_shows_its_own_subagents_model' test/test_nested_agents.py

echo
echo "=== Search fixture/data references for INTR and assistant entries ==="
rg -n '"agentId"|isSidechain|INTR|interrupted spawn|AssistantTranscriptEntry' test/test_data test/test_nested_agents.py

Repository: daaain/claude-code-log

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== test/test_nested_agents.py around the test ==="
sed -n '1,120p' test/test_nested_agents.py
echo
sed -n '230,405p' test/test_nested_agents.py

echo
echo "=== Locate _load_integrated and its fixture path ==="
rg -n 'def _load_integrated|_load_integrated\(|integrated' test/test_nested_agents.py test -g '!test/test_data/**' -g '!**/*.pyc'

echo
echo "=== Search candidate fixture files for INTR and sidechain assistant entries ==="
rg -n '"agentId":"INTR"|\"agentId\": \"INTR\"|INTR|isSidechain|AssistantTranscriptEntry' test/test_data -g '*.jsonl' -g '*.json'

echo
echo "=== If there is a specific integrated fixture path, print the lines around INTR entries ==="

Repository: daaain/claude-code-log

Length of output: 18089


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== nested_agents fixture files ==="
fd -t f '33330000-0000-4000-8000-000000000001*.jsonl' test/test_data/nested_agents

echo
echo "=== lines mentioning nsintr01 in the trunk fixture ==="
rg -n 'nsintr01|INTR|assistant|tool_use|tool_result|isSidechain' test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl

echo
echo "=== count entry types for nsintr01 sidechain records ==="
python3 - <<'PY'
import json
from collections import Counter
from pathlib import Path

path = Path("test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl")
counts = Counter()
rows = []
with path.open() as f:
    for i, line in enumerate(f, 1):
        obj = json.loads(line)
        msg = obj.get("message") or {}
        entry_type = obj.get("type")
        agent_id = obj.get("agentId") or msg.get("agentId")
        is_sidechain = obj.get("isSidechain")
        if agent_id == "nsintr01" or obj.get("spawnedAgentId") == "nsintr01":
            rows.append((i, entry_type, is_sidechain, agent_id, obj.get("spawnedAgentId"), msg.get("role"), msg.get("type")))
            counts[(entry_type, is_sidechain, msg.get("role"), msg.get("type"))] += 1

print("matching rows:", len(rows))
for row in rows[:50]:
    print(row)
print("counts:")
for k, v in counts.items():
    print(v, k)
PY

Repository: daaain/claude-code-log

Length of output: 6170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== renderer model-surfacing logic ==="
rg -n '_surface_agent_models|display_model|agent_model|spawnedAgentId|isSidechain' claude_code_log test -g '*.py'

echo
echo "=== relevant renderer slice ==="
sed -n '1,220p' claude_code_log/renderer.py

Repository: daaain/claude-code-log

Length of output: 50378


Exclude INTR from the expected spawn-model set. The fixture has no AssistantTranscriptEntry for nsintr01, and _surface_agent_models only stamps display_model when a spawned agent actually has one, so set(spawn_models) can never include model-nsintr01.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_nested_agents.py` around lines 370 - 390, The expected spawn-model
assertion in test_nested_agents.py should not include INTR because the fixture
never creates an AssistantTranscriptEntry for nsintr01 and _surface_agent_models
only assigns display_model for spawned agents that actually exist. Update the
spawn-model expectation in generate_template_messages-related assertions so it
derives from the agents present in entries (or explicitly excludes nsintr01)
while keeping the bijection check on spawn_models intact.

@cboos

cboos commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

(Claude) Re CodeRabbit's note on test_nested_agents.py (excluding nsintr01 from the expected spawn-model set): this is a false positive. The fixture does create an assistant entry for nsintr01subagents/agent-nsintr01.jsonl has an assistant turn with model: claude-haiku-4-5-20251001 — so it resolves to a spawn-card model and correctly belongs in the bijection set. The "interrupted spawn" in this fixture refers to the parent's meta-only link (no usable tool_result), not an absent assistant turn for the child. Verified: model-nsintr01 appears in spawn_models and the test passes green. Leaving the expectation as the full ALL_AGENTS set.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sub-agent messages should surface the model used

1 participant