Skip to content

Commit 778d280

Browse files
ShlomoSteptclaude
andcommitted
Fix markdown rendering in tool results with Python list content
When tool_result content is a Python list of content blocks (not a JSON string), the content was incorrectly rendered as raw JSON instead of markdown. This fix adds is_content_block_list() helper function and updates the tool_result handling to properly render markdown when content is a list containing content blocks with a "type" field. - Add is_content_block_list() function to detect Python list content blocks - Fix render_content_block() to call render_content_block_array() for list content - Add 10 new tests (8 for is_content_block_list, 2 for tool_result list rendering) - All 119 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 60eb0d8 commit 778d280

File tree

7 files changed

+485
-2
lines changed

7 files changed

+485
-2
lines changed

implementation_plan.md

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Implementation Plan: Fix Tool Result Markdown Rendering
2+
3+
## Goal
4+
5+
Fix the markdown rendering issue in tool results when content is a Python list of content blocks (not just a JSON string).
6+
7+
## Background
8+
9+
The current implementation correctly handles tool result content when it's a JSON string like:
10+
```json
11+
'[{"type": "text", "text": "## Report\n\n- Item 1"}]'
12+
```
13+
14+
However, it fails to render markdown when the content is already a Python list:
15+
```python
16+
[{"type": "text", "text": "## Report\n\n- Item 1"}]
17+
```
18+
19+
This causes markdown headers, lists, and code blocks to appear as raw text instead of rendered HTML.
20+
21+
## Root Cause Analysis
22+
23+
In `src/claude_code_transcripts/__init__.py`, the `render_content_block()` function has this logic for `tool_result` blocks (lines 1020-1084):
24+
25+
1. **Lines 1033-1044**: When `content` is a string, it checks if it's a content block array using `is_content_block_array()`, parses it, and calls `render_content_block_array()` to render markdown.
26+
27+
2. **Lines 1080-1081**: When `content` is a list, it immediately calls `format_json(content)` WITHOUT checking if it's a content block array that should be rendered as markdown.
28+
29+
```python
30+
# BUG: Line 1080 - doesn't check for content block array
31+
elif isinstance(content, list) or is_json_like(content):
32+
content_markdown_html = format_json(content) # Should render as markdown!
33+
```
34+
35+
## Proposed Changes
36+
37+
### File: [src/claude_code_transcripts/__init__.py](file:///Users/sys/Documents/_TEMP_project_to_redesign_new_computer_file_system/testing_projects/testing_claude_code_transcripts/claude-code-transcripts/src/claude_code_transcripts/__init__.py)
38+
39+
#### Change 1: Add `is_content_block_list()` helper function [NEW]
40+
41+
Add a new helper function after `is_content_block_array()` (around line 138) to detect when a Python list is a content block array:
42+
43+
```python
44+
def is_content_block_list(content):
45+
"""Check if content is a Python list of content blocks.
46+
47+
Args:
48+
content: Content to check.
49+
50+
Returns:
51+
True if content is a list containing dict items with 'type' keys.
52+
"""
53+
if not isinstance(content, list):
54+
return False
55+
if not content:
56+
return False
57+
# Check if items look like content blocks
58+
for item in content:
59+
if isinstance(item, dict) and "type" in item:
60+
return True
61+
return False
62+
```
63+
64+
#### Change 2: Fix tool_result handling for Python list content [MODIFY]
65+
66+
Modify lines 1080-1083 to check for content block lists and render them properly:
67+
68+
**Before:**
69+
```python
70+
elif isinstance(content, list) or is_json_like(content):
71+
content_markdown_html = format_json(content)
72+
else:
73+
content_markdown_html = format_json(content)
74+
```
75+
76+
**After:**
77+
```python
78+
elif isinstance(content, list):
79+
# Check if it's a content block array that should be rendered as markdown
80+
if is_content_block_list(content):
81+
rendered = render_content_block_array(content)
82+
if rendered:
83+
content_markdown_html = rendered
84+
else:
85+
content_markdown_html = format_json(content)
86+
else:
87+
content_markdown_html = format_json(content)
88+
else:
89+
content_markdown_html = format_json(content)
90+
```
91+
92+
### File: [tests/test_generate_html.py](file:///Users/sys/Documents/_TEMP_project_to_redesign_new_computer_file_system/testing_projects/testing_claude_code_transcripts/claude-code-transcripts/tests/test_generate_html.py)
93+
94+
#### Change 3: Add test for Python list content [NEW]
95+
96+
Add a new test after `test_tool_result_content_block_array_with_tool_use`:
97+
98+
```python
99+
def test_tool_result_content_block_list_renders_markdown(self, snapshot_html):
100+
"""Test that tool_result with content as Python list renders markdown properly."""
101+
block = {
102+
"type": "tool_result",
103+
"content": [
104+
{"type": "text", "text": "## Report\n\n- Item 1\n- Item 2\n\n```python\ncode\n```"}
105+
],
106+
"is_error": False,
107+
}
108+
result = render_content_block(block)
109+
# Should render as HTML, not raw JSON
110+
assert "<h2>Report</h2>" in result or "<h2>" in result
111+
assert "<li>Item 1</li>" in result or "<li>" in result
112+
# Should not show raw JSON structure
113+
assert '"type": "text"' not in result
114+
assert result == snapshot_html
115+
116+
def test_tool_result_content_block_list_with_multiple_blocks(self, snapshot_html):
117+
"""Test that tool_result with multiple text blocks renders all as markdown."""
118+
block = {
119+
"type": "tool_result",
120+
"content": [
121+
{"type": "text", "text": "## First Section\n\n- Point A"},
122+
{"type": "text", "text": "## Second Section\n\n- Point B"}
123+
],
124+
"is_error": False,
125+
}
126+
result = render_content_block(block)
127+
# Both sections should be rendered
128+
assert "First Section" in result
129+
assert "Second Section" in result
130+
assert "Point A" in result
131+
assert "Point B" in result
132+
# Should not show raw JSON
133+
assert '"type": "text"' not in result
134+
assert result == snapshot_html
135+
```
136+
137+
#### Change 4: Add test for `is_content_block_list` function [NEW]
138+
139+
Add tests for the new helper function:
140+
141+
```python
142+
class TestIsContentBlockList:
143+
"""Tests for is_content_block_list helper function."""
144+
145+
def test_empty_list(self):
146+
assert is_content_block_list([]) == False
147+
148+
def test_list_with_text_block(self):
149+
assert is_content_block_list([{"type": "text", "text": "hello"}]) == True
150+
151+
def test_list_with_image_block(self):
152+
assert is_content_block_list([{"type": "image", "source": {}}]) == True
153+
154+
def test_list_with_mixed_blocks(self):
155+
content = [
156+
{"type": "text", "text": "hello"},
157+
{"type": "image", "source": {}}
158+
]
159+
assert is_content_block_list(content) == True
160+
161+
def test_list_without_type(self):
162+
assert is_content_block_list([{"key": "value"}]) == False
163+
164+
def test_not_a_list(self):
165+
assert is_content_block_list("string") == False
166+
assert is_content_block_list({"type": "text"}) == False
167+
assert is_content_block_list(None) == False
168+
```
169+
170+
## Verification Plan
171+
172+
### Automated Tests
173+
174+
1. Run existing tests to ensure no regressions:
175+
```bash
176+
uv run pytest tests/test_generate_html.py -v
177+
```
178+
179+
2. Run new tests for the fix:
180+
```bash
181+
uv run pytest tests/test_generate_html.py::TestRenderContentBlock::test_tool_result_content_block_list_renders_markdown -v
182+
uv run pytest tests/test_generate_html.py::TestIsContentBlockList -v
183+
```
184+
185+
3. Update snapshots if needed:
186+
```bash
187+
uv run pytest --snapshot-update
188+
```
189+
190+
### Manual Verification
191+
192+
1. Create a test session JSON with tool result content as Python list
193+
2. Generate HTML using `uv run claude-code-transcripts`
194+
3. Verify markdown is rendered (headings, lists, code blocks display correctly)
195+
196+
## Items Requiring Review
197+
198+
> [!IMPORTANT]
199+
> The fix adds a new helper function `is_content_block_list()` which is similar to `is_content_block_array()` but for Python lists instead of JSON strings. Consider if these could be unified.
200+
201+
## Summary of Files to Change
202+
203+
| File | Action | Description |
204+
|------|--------|-------------|
205+
| `src/claude_code_transcripts/__init__.py` | MODIFY | Add `is_content_block_list()` function and fix tool_result handling |
206+
| `tests/test_generate_html.py` | MODIFY | Add tests for new functionality |
207+
| `tests/__snapshots__/*.ambr` | UPDATE | Snapshot updates from new tests |

src/claude_code_transcripts/__init__.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,26 @@ def is_content_block_array(text):
137137
return False
138138

139139

140+
def is_content_block_list(content):
141+
"""Check if content is a Python list of content blocks.
142+
143+
Args:
144+
content: Content to check.
145+
146+
Returns:
147+
True if content is a list containing dict items with 'type' keys.
148+
"""
149+
if not isinstance(content, list):
150+
return False
151+
if not content:
152+
return False
153+
# Check if items look like content blocks
154+
for item in content:
155+
if isinstance(item, dict) and "type" in item:
156+
return True
157+
return False
158+
159+
140160
def render_content_block_array(blocks):
141161
"""Render an array of content blocks.
142162
@@ -1077,8 +1097,16 @@ def render_content_block(block):
10771097
content_markdown_html = format_json(content)
10781098
else:
10791099
content_markdown_html = render_markdown_text(content)
1080-
elif isinstance(content, list) or is_json_like(content):
1081-
content_markdown_html = format_json(content)
1100+
elif isinstance(content, list):
1101+
# Check if it's a content block array that should be rendered as markdown
1102+
if is_content_block_list(content):
1103+
rendered = render_content_block_array(content)
1104+
if rendered:
1105+
content_markdown_html = rendered
1106+
else:
1107+
content_markdown_html = format_json(content)
1108+
else:
1109+
content_markdown_html = format_json(content)
10821110
else:
10831111
content_markdown_html = format_json(content)
10841112
return _macros.tool_result(content_markdown_html, content_json_html, is_error)

task.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Task Tracker: Fix Tool Result Markdown Rendering
2+
3+
## Status: Complete
4+
5+
### Completed Tasks
6+
7+
- [x] Review PR comments for markdown rendering issues
8+
- [x] Analyze current implementation - find render_content_block_array function
9+
- [x] Identify root cause of markdown not rendering in content block arrays
10+
- [x] Fix markdown rendering in content block arrays
11+
- [x] Verify subagent display (Task/Agent tools) works correctly
12+
- [x] Add tests for is_content_block_list function
13+
- [x] Add tests for tool_result with Python list content
14+
- [x] Run all tests (119 passed)
15+
- [x] Format code with black
16+
- [x] Commit changes to ShlomoStept fork
17+
18+
## Summary
19+
20+
Fixed the bug where tool result content as a Python list (not JSON string) was not rendering markdown properly. The fix adds a helper function `is_content_block_list()` and modifies the `tool_result` handling to render markdown when content is a list of content blocks.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div class="tool-result"><div class="tool-result-header"><span class="tool-result-label"><span class="result-icon"></span> Result</span><div class="view-toggle" role="tablist"><button class="view-toggle-tab active" role="tab" aria-selected="true" data-view="markdown">Markdown</button><button class="view-toggle-tab" role="tab" aria-selected="false" data-view="json">JSON</button></div></div><div class="truncatable"><div class="truncatable-content"><div class="view-markdown">
2+
<div class="assistant-text"><h2>Report</h2>
3+
<ul>
4+
<li>Item 1</li>
5+
<li>Item 2</li>
6+
</ul>
7+
<pre><code class="language-python">code
8+
</code></pre></div></div><div class="view-json"><pre class="json">[
9+
{
10+
&quot;type&quot;: &quot;text&quot;,
11+
&quot;text&quot;: &quot;## Report\n\n- Item 1\n- Item 2\n\n```python\ncode\n```&quot;
12+
}
13+
]</pre></div></div><button class="expand-btn">Show more</button></div></div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<div class="tool-result"><div class="tool-result-header"><span class="tool-result-label"><span class="result-icon"></span> Result</span><div class="view-toggle" role="tablist"><button class="view-toggle-tab active" role="tab" aria-selected="true" data-view="markdown">Markdown</button><button class="view-toggle-tab" role="tab" aria-selected="false" data-view="json">JSON</button></div></div><div class="truncatable"><div class="truncatable-content"><div class="view-markdown">
2+
<div class="assistant-text"><h2>First Section</h2>
3+
<ul>
4+
<li>Point A</li>
5+
</ul></div>
6+
<div class="assistant-text"><h2>Second Section</h2>
7+
<ul>
8+
<li>Point B</li>
9+
</ul></div></div><div class="view-json"><pre class="json">[
10+
{
11+
&quot;type&quot;: &quot;text&quot;,
12+
&quot;text&quot;: &quot;## First Section\n\n- Point A&quot;
13+
},
14+
{
15+
&quot;type&quot;: &quot;text&quot;,
16+
&quot;text&quot;: &quot;## Second Section\n\n- Point B&quot;
17+
}
18+
]</pre></div></div><button class="expand-btn">Show more</button></div></div>

tests/test_generate_html.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
render_json_with_markdown,
1515
format_json,
1616
is_json_like,
17+
is_content_block_list,
1718
render_todo_write,
1819
render_write_tool,
1920
render_edit_tool,
@@ -613,6 +614,75 @@ def test_tool_result_content_block_array_with_tool_use(self, snapshot_html):
613614
assert '"type": "tool_use"' not in result
614615
assert result == snapshot_html
615616

617+
def test_tool_result_content_block_list_renders_markdown(self, snapshot_html):
618+
"""Test that tool_result with content as Python list renders markdown properly."""
619+
block = {
620+
"type": "tool_result",
621+
"content": [
622+
{
623+
"type": "text",
624+
"text": "## Report\n\n- Item 1\n- Item 2\n\n```python\ncode\n```",
625+
}
626+
],
627+
"is_error": False,
628+
}
629+
result = render_content_block(block)
630+
# Should render as HTML, not raw JSON
631+
assert "<h2>" in result
632+
assert "<li>" in result
633+
# Should not show raw JSON structure
634+
assert '"type": "text"' not in result
635+
assert result == snapshot_html
636+
637+
def test_tool_result_content_block_list_with_multiple_blocks(self, snapshot_html):
638+
"""Test that tool_result with multiple text blocks renders all as markdown."""
639+
block = {
640+
"type": "tool_result",
641+
"content": [
642+
{"type": "text", "text": "## First Section\n\n- Point A"},
643+
{"type": "text", "text": "## Second Section\n\n- Point B"},
644+
],
645+
"is_error": False,
646+
}
647+
result = render_content_block(block)
648+
# Both sections should be rendered
649+
assert "First Section" in result
650+
assert "Second Section" in result
651+
assert "Point A" in result
652+
assert "Point B" in result
653+
# Should not show raw JSON
654+
assert '"type": "text"' not in result
655+
assert result == snapshot_html
656+
657+
658+
class TestIsContentBlockList:
659+
"""Tests for is_content_block_list helper function."""
660+
661+
def test_empty_list(self):
662+
assert is_content_block_list([]) is False
663+
664+
def test_list_with_text_block(self):
665+
assert is_content_block_list([{"type": "text", "text": "hello"}]) is True
666+
667+
def test_list_with_image_block(self):
668+
assert is_content_block_list([{"type": "image", "source": {}}]) is True
669+
670+
def test_list_with_mixed_blocks(self):
671+
content = [{"type": "text", "text": "hello"}, {"type": "image", "source": {}}]
672+
assert is_content_block_list(content) is True
673+
674+
def test_list_without_type(self):
675+
assert is_content_block_list([{"key": "value"}]) is False
676+
677+
def test_not_a_list_string(self):
678+
assert is_content_block_list("string") is False
679+
680+
def test_not_a_list_dict(self):
681+
assert is_content_block_list({"type": "text"}) is False
682+
683+
def test_not_a_list_none(self):
684+
assert is_content_block_list(None) is False
685+
616686

617687
class TestStripAnsi:
618688
"""Tests for ANSI escape stripping."""

0 commit comments

Comments
 (0)