Skip to content

Fold Skill body into its tool_use block#121

Merged
cboos merged 5 commits intomainfrom
dev/pair-skill-user-message
Apr 26, 2026
Merged

Fold Skill body into its tool_use block#121
cboos merged 5 commits 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 are paired with their expanded slash-command bodies and rendered as a single block; HTML adds a collapsible presentation of the skill body and Markdown appends the body beneath tool params.
    • Pairing metadata is propagated so renderers can fold related user meta entries into tool-use renderings.
  • Bug Fixes

    • Suppresses redundant "Launching skill" messages for paired Skill invocations.
  • Style

    • Added styling to visually nest and separate skill body content.
  • Tests

    • End-to-end tests verify pairing, scoping, suppression, and rendered output.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff871784-f078-49a9-825c-6704794600d9

📥 Commits

Reviewing files that changed from the base of the PR and between afb8e83 and d2baaac.

📒 Files selected for processing (1)
  • claude_code_log/renderer.py

📝 Walkthrough

Walkthrough

Fold Skill slash-command user entries into their corresponding Skill ToolUseMessage by pairing on (session_id, tool_use_id), attach the skill markdown as skill_body, remove the original slash-command and matching non-error "Launching skill:" tool_result, and render the body inline (HTML collapsible / Markdown raw).

Changes

Cohort / File(s) Summary
Data Model
claude_code_log/models.py
Added optional linkage fields: UserTranscriptEntry.sourceToolUseID, MessageMeta.source_tool_use_id, and ToolUseMessage.skill_body.
Metadata Factory
claude_code_log/factories/meta_factory.py
create_meta() now sets MessageMeta.source_tool_use_id from transcript.sourceToolUseID (or None).
Pairing Preprocess
claude_code_log/renderer.py
New pass folds Skill ToolUseMessage with matching isMeta=True user slash-command by (session_id, source_tool_use_id): attaches skill_body, removes the matched slash-command and the canonical non-error "Launching skill:" ToolResultMessage, and reindexes ctx. Includes robust _is_launching_skill_payload detection.
Rendering
claude_code_log/html/renderer.py, claude_code_log/markdown/renderer.py
Overrides format_ToolUseMessage() to append skill_body when present: HTML renders it as a collapsible markdown block (render_markdown_collapsible) with fixed section id "skill-body", Markdown appends the raw skill_body beneath params.
Styling
claude_code_log/html/templates/components/message_styles.css
Added .skill-body CSS rules (top margin, padding, left border using --tool-param-sep-color, smaller font).
Tests & Snapshots
test/test_skill_pairing.py, test/__snapshots__/test_snapshot_html.ambr
New end-to-end tests for pairing, scoping, edge cases, and guarded drop logic; updated HTML snapshot includes .skill-body CSS.

Sequence Diagram

sequenceDiagram
    participant Transcript as Transcript
    participant Pairer as SkillPairingLogic
    participant Messages as MessageStream
    participant Renderer as Renderer
    participant Output as RenderedOutput

    Transcript->>Pairer: Emit entries (tool_use "Skill", tool_result "Launching skill:", user meta with sourceToolUseID)
    Pairer->>Pairer: Detect Skill tool_use and find matching meta by (session_id, sourceToolUseID)
    Pairer->>Pairer: Extract slash-command body -> attach to ToolUseMessage.skill_body
    Pairer->>Pairer: Mark matched user/meta and canonical tool_result as consumed
    Pairer->>Messages: Remove consumed entries and reindex stream
    Messages->>Renderer: Provide ToolUseMessage (with skill_body)
    Renderer->>Output: Render tool_use + collapsible / appended skill body
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I found a Skill and gave a hop,
I tucked its body under the tool-use top.
No stray slash-command left to roam,
Skill and tool now cozy at home.
Hooray — a tidy render, off I hop!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.71% 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 directly describes the main change: folding Skill body content into the tool_use block, which matches the core objective of pairing skill invocations.
Linked Issues check ✅ Passed The PR comprehensively implements issue #93 requirements: it embeds skill content into the Skill tool block via skill_body field, removes redundant user messages, and updates renderers to display the body nested within the tool_use block.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the skill body pairing feature: model fields, renderer logic, CSS styling, factory updates, and comprehensive tests. No unrelated modifications are present.

✏️ 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 thread claude_code_log/renderer.py
CodeRabbit on PR #121 caught two failure modes in `_pair_skill_tool_uses`
that the original commit (dc1770a) didn't defend against. Both are
session-/payload-precision concerns that surface in combined transcripts
or with malformed/error tool_results.

## #1 — Cross-session pairing collision

The lookup was keyed on `tool_use_id` alone. `RenderingContext.messages`
spans combined transcripts (multiple sessions), but Anthropic tool_use
ids are only session-unique. A stray collision would let session B's
slash body fold into session A's Skill tool_use (or vice versa) silently.

Fix: key the lookup by `(render_session_id, source_tool_use_id)` and
match the same compound on the consuming side. `render_session_id` is
fork-aware so within-session branches stay distinct too.

## #2 — Real error tool_results silently dropped

The original code dropped EVERY tool_result with the matching
`tool_use_id`, on the assumption that only the canonical
`"Launching skill: <name>"` result carries that id. A real error result
("Skill 'X' not found", `is_error=True`) would have the same id and get
swallowed too — hiding the failure from the user entirely.

Fix: only drop tool_results that are (a) in the same session,
(b) `is_error=False`, AND (c) whose payload starts with
`"Launching skill:"`. New `_is_launching_skill_payload` helper handles
both string- and list-shaped `ToolResultContent.content`.

## Tests (3 new in TestSkillPairing)

- `test_same_tool_use_id_across_sessions_does_not_cross_pair` — combined
  transcript with two sessions sharing `tool_use_id="toolu_DUP"` but
  different bodies; asserts each Skill keeps its own session's body.
- `test_error_tool_result_with_same_id_is_preserved` — a Skill failure
  flow where the error result shares the tool_use_id; asserts the error
  survives while the canonical "Launching skill:" result is still dropped.
- `test_non_launching_skill_result_with_same_id_is_preserved` — divergent
  payload sharing the id (e.g. malformed transcript) survives.

All three fail on the pre-fix code (cross-pair, swallow error, swallow
divergent payload) and pass after.

Refs #93 / #121.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos added 2 commits April 25, 2026 00:38
The merge with main brings in the teammates fixture and snapshot tests
added by the recent rendering work. The teammates snapshot embeds the
full message_styles.css bundle, which now includes a `.skill-body`
rule from this branch's Skill pairing changes. CI on the merged head
fails because the snapshot was captured pre-`.skill-body`.

Sequential `--snapshot-update` (per the project's snapshot guidance)
adds the missing 11 lines to the teammates fixture snapshot. Both
parallel and sequential test runs now show 12/12 passing.
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.

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

2148-2175: Optional: pre-index tool_results for the inner scan.

PR description already calls out the O(K·N) inner search as a later perf pass. If you revisit it, a (render_session_id, tool_use_id) -> list[TemplateMessage] map built in the same sweep as slash_by_source turns the nested loop into a direct lookup, with the prefix / is_error guards unchanged. Skipping for now given the PR is already large and the Skill tool fan-out is small in practice.

🤖 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 2148 - 2175, The inner O(K·N) scan
over ctx.messages to find matching ToolResultMessage should be replaced by a
pre-indexed map so we can do a direct lookup: during the same sweep that builds
slash_by_source, also build a dict mapping (render_session_id, tool_use_id) ->
list[ToolResultMessage] (only include messages with message_index not None and
not is_error), then in the loop for each ToolUseMessage (the block that
references slash_by_source, consumed_indices, UserSlashCommandMessage) replace
the nested for other in ctx.messages scan with a lookup into that dict and
iterate that small list, applying the existing
_is_launching_skill_payload(other.content.output) check and adding
other.message_index to consumed_indices when matched.
claude_code_log/html/renderer.py (1)

427-436: Optional: mirror the Markdown renderer's empty-rendered guard.

MarkdownRenderer.format_ToolUseMessage returns content.skill_body verbatim when super() yields "" (see snippet from claude_code_log/markdown/renderer.py:676-690). Here the concatenation is unconditional, so a Skill ToolUseMessage whose params table renders to "" would emit a leading newline before body_html. Harmless in HTML, but the two renderers drift in behavior for the same content.

♻️ Optional parity tweak
         rendered = super().format_ToolUseMessage(content, message)
         if content.skill_body:
             body_html = render_markdown_collapsible(
                 content.skill_body,
                 "skill-body",
                 line_threshold=30,
                 preview_line_count=10,
             )
-            rendered = f"{rendered}\n{body_html}"
+            rendered = f"{rendered}\n{body_html}" if rendered else body_html
         return rendered
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/renderer.py` around lines 427 - 436, The HTML renderer
unconditionally appends the collapsible skill body which causes a leading
newline when super().format_ToolUseMessage returns an empty string; update
claude_code_log/html/renderer.py in format_ToolUseMessage to mirror the Markdown
renderer's empty-rendered guard (check the local rendered variable returned by
super()) and only prepend a newline and append body_html when rendered is
non-empty, otherwise set rendered to body_html directly; reference rendered,
content.skill_body, format_ToolUseMessage, and render_markdown_collapsible to
locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@claude_code_log/html/renderer.py`:
- Around line 427-436: The HTML renderer unconditionally appends the collapsible
skill body which causes a leading newline when super().format_ToolUseMessage
returns an empty string; update claude_code_log/html/renderer.py in
format_ToolUseMessage to mirror the Markdown renderer's empty-rendered guard
(check the local rendered variable returned by super()) and only prepend a
newline and append body_html when rendered is non-empty, otherwise set rendered
to body_html directly; reference rendered, content.skill_body,
format_ToolUseMessage, and render_markdown_collapsible to locate the change.

In `@claude_code_log/renderer.py`:
- Around line 2148-2175: The inner O(K·N) scan over ctx.messages to find
matching ToolResultMessage should be replaced by a pre-indexed map so we can do
a direct lookup: during the same sweep that builds slash_by_source, also build a
dict mapping (render_session_id, tool_use_id) -> list[ToolResultMessage] (only
include messages with message_index not None and not is_error), then in the loop
for each ToolUseMessage (the block that references slash_by_source,
consumed_indices, UserSlashCommandMessage) replace the nested for other in
ctx.messages scan with a lookup into that dict and iterate that small list,
applying the existing _is_launching_skill_payload(other.content.output) check
and adding other.message_index to consumed_indices when matched.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4405d23b-7975-46b3-b003-3f5922d44be8

📥 Commits

Reviewing files that changed from the base of the PR and between a584d7a and afb8e83.

📒 Files selected for processing (6)
  • claude_code_log/factories/meta_factory.py
  • claude_code_log/html/renderer.py
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
✅ Files skipped from review due to trivial changes (2)
  • claude_code_log/factories/meta_factory.py
  • test/snapshots/test_snapshot_html.ambr
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/markdown/renderer.py

pyright (strict) flags two checks as `reportUnnecessaryIsInstance`:

- `isinstance(content, list)` after `isinstance(content, str)` returned
  False — content is `Union[str, list[dict[str, Any]]]` (Pydantic-typed),
  so after the str-narrow the type IS list.
- `isinstance(item, dict)` inside the iteration — items in
  `list[dict[str, Any]]` are already typed `dict`.

Both checks were defensive runtime guards, but the Pydantic schema makes
them unreachable. Drop the type-redundant isinstance, keep the
`isinstance(text, str)` for the `Any`-typed `.get("text")` value (that
one IS necessary — the dict value type is `Any`).

Inline comment explains the surviving narrowing path.

ty (which doesn't enforce reportUnnecessaryIsInstance) was already
clean. Local + CI pyright now both green.
@cboos cboos merged commit 978b2db into main Apr 26, 2026
11 checks passed
cboos added a commit that referenced this pull request Apr 26, 2026
Fixup for #121: a Skill invocation rendered as the generic tool
header ("🛠️ Skill") plus a "skill / <name>" params row, leaving the
folded body visually disjoint from the title that names it. Now the
title carries everything — "💡 Skill <name>" — and the params row is
suppressed since the only field (skill) is in the title and the body
folds in below via skill_body.

- New SkillInput typed model (skill: str, extra="allow" so future
  Skill input shapes don't fall back to the generic table).
- title_SkillInput / format_SkillInput in HTML and Markdown renderers
  produce the new title and an empty body slot.
- The .skill-body inner left accent is gone — the outer .tool_use
  green border already frames the block, the inner accent only made
  it look doubly-framed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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