Skip to content

Fold Skill body into its tool_use block#121

Open
cboos wants to merge 1 commit intomainfrom
dev/pair-skill-user-message
Open

Fold Skill body into its tool_use block#121
cboos wants to merge 1 commit intomainfrom
dev/pair-skill-user-message

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Apr 19, 2026

Closes #93.

Claude Code's Skill invocation lands on disk as three disjoint entries:

  1. assistant Skill tool_use
  2. user tool_result with the literal string "Launching skill: <name>"
  3. user isMeta=True entry whose sourceToolUseID matches (1) and whose text is the expanded skill body (markdown, often 100+ lines)

Rendered as-is, (3) appears as a "🧑 User (slash command)" block visually disjoint from (1) — which is exactly what #93 flags: "seeing the skill content as a user message (right aligned) breaks the 'flow'."

The linkage is already in the data. (3) carries a top-level sourceToolUseID equal to (1)'s tool_use id. No heuristic, no parent-chain walk, no proximity guess — a field lookup.

Changes

  • UserTranscriptEntry.sourceToolUseID: Optional[str] — Pydantic field.
  • MessageMeta.source_tool_use_id — propagated via meta_factory (getattr-with-default handles older transcripts).
  • ToolUseMessage.skill_body: Optional[str] — set only when paired.
  • New _pair_skill_tool_uses(ctx) in renderer.py: single-pass indexing of slash-command TemplateMessages by source_tool_use_id, then folds the body into each Skill ToolUseMessage and drops the slash-command + the redundant "Launching skill" tool_result. Runs right after _render_messages (before detail filtering) and reuses _reindex_filtered_context for the drops.
  • HTML renderer overrides format_ToolUseMessage: appends the body via render_markdown_collapsible (same collapsible primitive as slash-command / compacted-summary).
  • Markdown renderer does the same with raw passthrough.
  • New .skill-body CSS — left border + inset padding — makes the folded body read as nested content under the params table.

Detail-level interaction

Pairing runs before _filter_template_by_detail. At HIGH the body survives alongside the tool_use — intended, since once paired the body is content of the Skill tool_use, not an independent message. MINIMAL/LOW continue to drop the entire Skill invocation because ToolUseMessage itself is in those exclude chains.

Tests (8 new in test/test_skill_pairing.py)

Template-level:

  • Body folds into ToolUseMessage.skill_body.
  • Slash-command TemplateMessage is consumed.
  • "Launching skill: X" tool_result is dropped.
  • Non-Skill tool_uses (e.g. Bash) untouched — their tool_result stays.
  • isMeta entries without sourceToolUseID still render as standalone slash-commands.
  • Orphan body (sourceToolUseID pointing at missing tool_use) stays standalone.

Renderer output:

  • HTML: .skill-body class + rendered markdown present, "Launching skill" absent.
  • Markdown: raw markdown passthrough, "Launching skill" absent.

Validation

  • just test (excluding tui/browser/pagination): 922 passed, 7 skipped.
  • uv run pyright on modified files: 0 errors, 0 warnings.
  • uv run ty check: All checks passed (0 diagnostics).
  • ruff format + ruff check: clean.
  • Edge-case snapshot regenerated (CSS addition only, no behaviour change).

Review

Monk approved at dc1770a. Optional non-blocker flagged: the inner tool_result search in _pair_skill_tool_uses is O(N·M) for typical transcripts with a handful of Skills; could be tightened to O(N+M) by indexing tool_results once. Left for a later perf pass since the actual cost is negligible.

Summary by CodeRabbit

  • New Features

    • Skill tool invocations now display as a unified block with the skill body integrated within the tool use, rather than appearing as separate entries.
    • Redundant "Launching skill" system messages are automatically removed to reduce log clutter.
  • Tests

    • Added comprehensive test coverage for skill tool pairing behavior and rendering output in both HTML and Markdown formats.

Claude Code's Skill invocation lands on disk as three disjoint
entries:

  1. assistant `Skill` tool_use
  2. user tool_result with the literal string "Launching skill: <name>"
  3. user `isMeta=True` entry whose `sourceToolUseID` matches (1) and
     whose text is the expanded skill body (markdown, often 100+
     lines)

Rendered as-is, (3) shows up as a disjoint "🧑 User (slash command)"
block visually unrelated to (1) — which is exactly what #93 flags:
"seeing the skill content as a user message (right aligned) breaks
the 'flow'."

The linkage we need is already in the data. (3) carries a top-level
`sourceToolUseID` equal to (1)'s tool_use id. No heuristic, no
parent-chain walk, no proximity guess — a field lookup.

## Changes

- `UserTranscriptEntry` gains `sourceToolUseID: Optional[str]` so
  the field survives Pydantic parsing.
- `MessageMeta` gains `source_tool_use_id` and `meta_factory` forwards
  the value through.
- `ToolUseMessage` gains `skill_body: Optional[str]` — set only for
  Skill invocations that found a matching (3).
- `_pair_skill_tool_uses(ctx)` in renderer.py walks ctx.messages
  once: it indexes slash-command TemplateMessages by
  `meta.source_tool_use_id`, then for each `ToolUseMessage` with
  `tool_name == "Skill"` attaches the matching slash-command's
  text as `skill_body` and marks both the slash-command and the
  matching "Launching skill" tool_result as consumed. Consumed
  indices feed through the existing `_reindex_filtered_context` so
  the rest of the pipeline sees a clean, gap-free list.
- Called before the detail-level post-render filter so the body
  survives alongside the tool_use at HIGH — arguably correct per
  main's "body is content of the Skill tool_use, not an independent
  message" framing. MINIMAL/LOW still drop the whole skill
  invocation since tool_use itself is filtered there.
- HTML renderer overrides `format_ToolUseMessage`: after the
  standard params-table render, appends the body via
  `render_markdown_collapsible(..., "skill-body", …)` so long
  bodies collapse like existing slash-command / compacted-summary
  rendering.
- Markdown renderer does the same with raw markdown passthrough.
- New `.skill-body` CSS rule gives the embedded body a left border
  and inset padding so it reads as nested content under the
  tool invocation, not a sibling message.

## Tests (8 new in test_skill_pairing.py)

Template-level:
- Body folds into the ToolUseMessage as skill_body
- Slash-command TemplateMessage is consumed (removed from ctx.messages)
- "Launching skill: X" tool_result is dropped
- Non-Skill tool_uses (e.g. Bash) are untouched — their tool_result stays
- isMeta entries without sourceToolUseID still render as slash-commands
- Orphan skill body (pointing at a missing tool_use) stays as a standalone
  slash-command

Renderer output:
- HTML: `.skill-body` class and rendered markdown (<strong> etc.) land
  in the output; "Launching skill:" string is absent
- Markdown: raw markdown passes through; "Launching skill:" absent

## Scope checks

- No new pair primitive — uses `_reindex_filtered_context` to drop
  consumed messages.
- Narrow dispatch: only `tool_name == "Skill"` is paired.
- Fall-back-clean: entries without `sourceToolUseID` (older Claude
  Code versions) behave identically to current main.
- Edge-case snapshot regenerated mechanically (CSS addition only).

All 922 unit tests pass. pyright / ty / ruff clean on modified files.

Refs #93.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

The PR implements pairing of Claude Code's "Skill" tool invocations with their expanded body content, embedding the skill body directly within the tool-use block instead of rendering it as a separate user message. Changes span the data model, message pairing logic, metadata factory, and renderers.

Changes

Cohort / File(s) Summary
Data Model Extensions
claude_code_log/models.py
Added optional fields to link skill metadata with tool invocations: sourceToolUseID in UserTranscriptEntry, source_tool_use_id in MessageMeta, and skill_body in ToolUseMessage.
Skill Pairing Logic
claude_code_log/renderer.py
Introduced _pair_skill_tool_uses() function to detect Skill tool_use messages, locate matching user meta entries via source_tool_use_id, fold the slash-command body into skill_body, drop redundant tool_result entries, and reindex the message stream. Integrated into the generate_template_messages() pipeline.
Metadata Factory
claude_code_log/factories/meta_factory.py
Updated create_meta() to populate MessageMeta.source_tool_use_id from transcript.sourceToolUseID.
HTML Rendering & Styling
claude_code_log/html/renderer.py, claude_code_log/html/templates/components/message_styles.css
Added HtmlRenderer.format_ToolUseMessage() override to append skill_body as a collapsible section; introduced .skill-body CSS class for layout, padding, left border, and reduced font size.
Markdown Rendering
claude_code_log/markdown/renderer.py
Added MarkdownRenderer.format_ToolUseMessage() override to append skill_body as raw markdown beneath the base rendering.
Test Suite & Snapshots
test/test_skill_pairing.py, test/__snapshots__/test_snapshot_html.ambr
Added comprehensive test suite covering skill pairing behavior, message consumption, non-skill tool independence, orphan body handling, and HTML/Markdown rendering output. Updated HTML snapshot with .skill-body CSS rule across sections.

Sequence Diagram

sequenceDiagram
    participant Transcript as Transcript<br/>(raw entries)
    participant Pairing as Skill Pairing<br/>Logic
    participant Messages as Message<br/>Stream
    participant Renderer as HTML/Markdown<br/>Renderer
    participant Output as Rendered<br/>Output

    Transcript->>Pairing: tool_use("Skill", id="X")<br/>tool_result("Launching...")<br/>user_meta(sourceToolUseID="X")
    Pairing->>Pairing: Detect Skill tool_use
    Pairing->>Pairing: Match meta entry by<br/>source_tool_use_id
    Pairing->>Pairing: Extract skill_body from<br/>slash-command content
    Pairing->>Messages: Attach skill_body to<br/>tool_use, remove meta<br/>& redundant tool_result
    Pairing->>Messages: Reindex message stream
    Messages->>Renderer: ToolUseMessage with<br/>skill_body populated
    Renderer->>Output: Render tool_use +<br/>collapsible skill_body<br/>as single unit
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A Skill now pairs with grace so bright,
No lonely message on the right!
The body folds with tool-use tight,
One cohesive block in sight.
Hopping through the render light! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.62% 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 'Fold Skill body into its tool_use block' accurately and concisely summarizes the primary change: pairing skill bodies with Skill tool_use entries rather than displaying them as separate messages.
Linked Issues check ✅ Passed The PR successfully implements all coding objectives from issue #93: adds sourceToolUseID/source_tool_use_id fields for linking, creates _pair_skill_tool_uses() to fold bodies into ToolUseMessage.skill_body, removes slash-command and tool_result entries, and updates renderers to display the paired content.
Out of Scope Changes check ✅ Passed All changes are directly scoped to issue #93 objectives: model fields for pairing, preprocessing logic, renderer implementations, CSS styling, and comprehensive tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/pair-skill-user-message

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/renderer.py`:
- Around line 2032-2091: The pairing currently matches only on tool_use_id and
can collide across sessions and can drop real results; modify
_pair_skill_tool_uses to key slash_by_source by (source_tool_use_id,
session_key) where session_key is taken from a session/render identifier on
messages (e.g. msg.meta.render_session_id or msg.meta.session_id) so lookups use
the same session, update the lookup and the later slash =
slash_by_source.get(...) usage to use that composite key, and when marking
matching tool_result entries for removal only include tool_result messages in
the same session whose visible text/content begins with "Launching skill:" (and
skip any tool_result that indicates an error/status != success if such meta
exists) instead of removing all tool_results with the same tool_use_id. This
ensures you only fold the intended slash body and drop the redundant "Launching
skill:" result within the same session.
🪄 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: 93790577-4afc-4767-906c-c6381acefe04

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd392d and dc1770a.

📒 Files selected for processing (8)
  • claude_code_log/factories/meta_factory.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/templates/components/message_styles.css
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
  • test/test_skill_pairing.py

Comment on lines +2032 to +2091
def _pair_skill_tool_uses(ctx: RenderingContext) -> None:
"""Fold the `isMeta=True` user body of a Skill invocation into its tool_use.

Claude Code emits three separate entries for a Skill invocation:
1. assistant `Skill` tool_use
2. user tool_result containing the literal string "Launching skill: <name>"
3. user `isMeta=True` entry whose `sourceToolUseID` matches (1) and whose
text is the expanded skill body (markdown, often 100+ lines).

Rendered as-is, (3) appears as a bare "🧑 User (slash command)" block
visually disjoint from (1). Pair them: attach (3)'s text as
`skill_body` on the Skill `ToolUseMessage`, drop (2) and (3) from
`ctx.messages`, and re-index so later passes see a clean slate.

See issue #93.
"""
# Build the lookup: tool_use_id -> UserSlashCommandMessage template
slash_by_source: dict[str, TemplateMessage] = {}
for msg in ctx.messages:
if (
isinstance(msg.content, UserSlashCommandMessage)
and msg.meta.source_tool_use_id
):
slash_by_source[msg.meta.source_tool_use_id] = msg

if not slash_by_source:
return

consumed_indices: set[int] = set()
for msg in ctx.messages:
if not (
isinstance(msg.content, ToolUseMessage) and msg.content.tool_name == "Skill"
):
continue
slash = slash_by_source.get(msg.content.tool_use_id)
if slash is None or not isinstance(slash.content, UserSlashCommandMessage):
continue
# Fold the body into the Skill tool_use and mark the slash-command consumed.
msg.content.skill_body = slash.content.text
if slash.message_index is not None:
consumed_indices.add(slash.message_index)
# The matching tool_result carries the redundant "Launching skill: ..."
# string; drop it too so the tool_use stands alone as one visual unit.
for other in ctx.messages:
if (
other.type == "tool_result"
and other.tool_use_id == msg.content.tool_use_id
and other.message_index is not None
):
consumed_indices.add(other.message_index)

if not consumed_indices:
return

kept = [
msg
for msg in ctx.messages
if msg.message_index is None or msg.message_index not in consumed_indices
]
_reindex_filtered_context(ctx, kept)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Scope Skill pairing and result removal more tightly.

This currently matches sourceToolUseID globally and drops every tool_result with the same tool_use_id. In combined/multi-session renders, an ID collision can fold the wrong slash body, and a real/error Skill result with the same ID would be hidden. Please key by session/render session and only drop the known redundant non-error "Launching skill:" result.

🐛 Proposed tightening
 def _pair_skill_tool_uses(ctx: RenderingContext) -> None:
@@
-    # Build the lookup: tool_use_id -> UserSlashCommandMessage template
-    slash_by_source: dict[str, TemplateMessage] = {}
+    # Build the lookup: (render_session_id, tool_use_id) -> slash-command template
+    slash_by_source: dict[tuple[str, str], TemplateMessage] = {}
     for msg in ctx.messages:
         if (
             isinstance(msg.content, UserSlashCommandMessage)
             and msg.meta.source_tool_use_id
         ):
-            slash_by_source[msg.meta.source_tool_use_id] = msg
+            slash_by_source[(msg.render_session_id, msg.meta.source_tool_use_id)] = msg
@@
-        slash = slash_by_source.get(msg.content.tool_use_id)
+        slash = slash_by_source.get((msg.render_session_id, msg.content.tool_use_id))
         if slash is None or not isinstance(slash.content, UserSlashCommandMessage):
             continue
@@
         for other in ctx.messages:
             if (
-                other.type == "tool_result"
-                and other.tool_use_id == msg.content.tool_use_id
+                isinstance(other.content, ToolResultMessage)
+                and other.render_session_id == msg.render_session_id
+                and other.content.tool_use_id == msg.content.tool_use_id
+                and not other.content.is_error
                 and other.message_index is not None
             ):
-                consumed_indices.add(other.message_index)
+                output = other.content.output
+                if (
+                    isinstance(output, ToolResultContent)
+                    and isinstance(output.content, str)
+                    and output.content.strip().startswith("Launching skill:")
+                ):
+                    consumed_indices.add(other.message_index)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 2032 - 2091, The pairing currently
matches only on tool_use_id and can collide across sessions and can drop real
results; modify _pair_skill_tool_uses to key slash_by_source by
(source_tool_use_id, session_key) where session_key is taken from a
session/render identifier on messages (e.g. msg.meta.render_session_id or
msg.meta.session_id) so lookups use the same session, update the lookup and the
later slash = slash_by_source.get(...) usage to use that composite key, and when
marking matching tool_result entries for removal only include tool_result
messages in the same session whose visible text/content begins with "Launching
skill:" (and skip any tool_result that indicates an error/status != success if
such meta exists) instead of removing all tool_results with the same
tool_use_id. This ensures you only fold the intended slash body and drop the
redundant "Launching skill:" result within the same session.

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.

Pair skills and user messages

1 participant