Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,23 @@ def _json_result_table_html(raw_content: str) -> Optional[str]:
return f"<div class='tool-result-json'>{_params_root_html(table_html)}</div>"


def _structured_items_html(items: "list[dict[str, Any]]") -> str:
"""Render leftover typed content items as a JSON/Markdown hybrid table.

Used for tool results whose ``content`` is a list carrying items other
than ``text``/``image`` — e.g. ToolSearch's ``tool_reference`` blocks.
These used to be dropped, leaving the result side blank (issue #227);
now they render with the same table machinery as JSON results.
"""
raw = json.dumps(items, ensure_ascii=False)
table = _json_result_table_html(raw)
if table is not None:
return table
# Breadth-capped (or otherwise non-tabular): fall back to a JSON dump.
pretty = json.dumps(items, indent=2, ensure_ascii=False)
return f"<div class='tool-result-json'>{_json_dump_value_html(pretty)}</div>"


def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"""Format raw ToolResultContent as HTML (fallback formatter).

Expand All @@ -1289,10 +1306,15 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
raw_content = tool_result.content
has_images = False
image_html_parts: list[str] = []
structured_html = ""
else:
# Content is a list of structured items, extract text and images
# Content is a list of structured items, extract text and images.
# Any other typed item (e.g. ``tool_reference`` from ToolSearch) is
# kept verbatim and rendered as a JSON/Markdown hybrid table below,
# instead of being silently dropped (issue #227).
content_parts: list[str] = []
image_html_parts: list[str] = []
structured_items: list[dict[str, Any]] = []
for item in tool_result.content:
item_type = item.get("type")
if item_type == "text":
Expand Down Expand Up @@ -1325,8 +1347,16 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
f'<img src="{escape_html(data_url)}" alt="Tool result image" '
f'class="tool-result-image" />'
)
else:
# Any other typed item (the loop already assumes dict via
# ``item.get`` above) — keep it for the JSON table.
structured_items.append(item)
raw_content = "\n".join(content_parts)
has_images = len(image_html_parts) > 0
# Render leftover typed items (no text, not images) as a JSON table.
structured_html = (
_structured_items_html(structured_items) if structured_items else ""
)

# Strip <tool_use_error> XML tags but keep the content inside
# Also strip redundant "String: ..." portions that echo the input
Expand All @@ -1345,10 +1375,10 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:

# Build final HTML based on content length and presence of images
if has_images:
# Combine text and images
# Combine text, structured items and images
text_html = f"<pre>{full_html}</pre>" if full_html else ""
images_html = "".join(image_html_parts)
combined_content = f"{text_html}{images_html}"
combined_content = f"{text_html}{structured_html}{images_html}"

# Always make collapsible when images are present
preview_text = "Text and image content"
Expand All @@ -1363,6 +1393,15 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
</details>
"""
else:
# Typed items with no plain text (e.g. ToolSearch's tool_reference):
# the JSON table is the whole result (issue #227). When text is also
# present, show it first, then the structured items.
if structured_html:
if not raw_content:
return structured_html
text_html = f"<pre>{full_html}</pre>" if full_html else ""
return f"{text_html}{structured_html}"

# Text-only content that parses as structured JSON renders as a
# params-style table (not for errors — those read as text).
if not tool_result.is_error:
Expand Down
22 changes: 21 additions & 1 deletion claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1681,7 +1681,27 @@ def format_ToolResultContent(
if isinstance(output.content, str):
text = strip_error_tags(output.content)
return self._code_fence(text)
return self._code_fence(json.dumps(output.content, indent=2), "json")
# Structured list content: render text items as text and keep any
# other typed items (e.g. ToolSearch's tool_reference) as a JSON
# block, instead of dumping the whole list verbatim (issue #227).
text_parts: list[str] = []
structured_items: list[dict[str, Any]] = []
for item in output.content:
if item.get("type") == "text":
text_parts.append(str(item.get("text", "")))
else:
structured_items.append(item)
parts: list[str] = []
text = strip_error_tags("\n".join(text_parts)).strip()
if text:
parts.append(text)
if structured_items:
parts.append(
self._code_fence(
json.dumps(structured_items, indent=2, ensure_ascii=False), "json"
)
)
return "\n\n".join(parts)

# -------------------------------------------------------------------------
# Title Methods (for tool use dispatch)
Expand Down
161 changes: 161 additions & 0 deletions test/test_generic_tool_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Tests for generic (fallback) tool-result rendering of structured content.

For a tool without a specialized output parser (e.g. ToolSearch), the
result ``content`` can be a list of typed items other than ``text`` /
``image`` — notably ``tool_reference`` blocks. These used to be silently
dropped, leaving the result side blank (issue #227). Both the HTML and
Markdown fallbacks must now surface them.
"""

from typing import Any, Union

from claude_code_log.html.tool_formatters import format_tool_result_content_raw
from claude_code_log.markdown.renderer import MarkdownRenderer
from claude_code_log.models import (
MessageMeta,
ToolResultContent,
ToolResultMessage,
)
from claude_code_log.renderer import TemplateMessage


def _result(
content: Union[str, list[dict[str, Any]]], is_error: bool = False
) -> ToolResultContent:
return ToolResultContent(
type="tool_result", tool_use_id="tu", content=content, is_error=is_error
)


def _md_message(
output: ToolResultContent, tool_name: str = "ToolSearch"
) -> TemplateMessage:
meta = MessageMeta(
uuid="u", session_id="s", timestamp="2025-01-01T00:00:00Z", is_sidechain=False
)
content = ToolResultMessage(
meta=meta,
output=output,
tool_use_id="tu",
tool_name=tool_name,
)
return TemplateMessage(content)


# A real ToolSearch result shape (issue #227).
_TOOL_REFERENCE = {
"type": "tool_reference",
"tool_name": "mcp__plugin_clmail__terminal",
}


class TestHtmlStructuredContent:
def test_tool_reference_is_not_dropped(self):
"""The bug: a tool_reference-only result rendered nothing."""
html = format_tool_result_content_raw(_result([_TOOL_REFERENCE]))
assert html.strip()
assert "tool-result-json" in html
assert "mcp__plugin_clmail__terminal" in html
# Not an empty <pre>.
assert html.strip() != "<pre></pre>"

def test_multiple_tool_references(self):
html = format_tool_result_content_raw(
_result(
[
{"type": "tool_reference", "tool_name": "WebSearch"},
{"type": "tool_reference", "tool_name": "WebFetch"},
]
)
)
assert "WebSearch" in html
assert "WebFetch" in html

def test_text_and_structured_items_both_render(self):
html = format_tool_result_content_raw(
_result(
[
{"type": "text", "text": "some text result"},
_TOOL_REFERENCE,
]
)
)
assert "some text result" in html
assert "mcp__plugin_clmail__terminal" in html

def test_structured_items_escape_html(self):
"""Generated table must escape, never inject raw HTML."""
html = format_tool_result_content_raw(
_result([{"type": "tool_reference", "tool_name": "<script>x</script>"}])
)
assert "<script>" not in html
assert "&lt;script&gt;" in html

def test_is_error_structured_still_renders_table(self):
"""A typed-item result still renders the table when is_error.

Decision (cboos): tool_reference (and other typed) items are not
error *message text*, so they keep the structured table rendering —
the is_error->read-as-text guard only covers the JSON-string path.
Pins the ordering so a future refactor can't move the guard ahead
of the structured path and silently swallow these.
"""
html = format_tool_result_content_raw(_result([_TOOL_REFERENCE], is_error=True))
assert "tool-result-json" in html
assert "mcp__plugin_clmail__terminal" in html

def test_string_content_unchanged(self):
"""Plain string results keep the legacy <pre> rendering."""
html = format_tool_result_content_raw(_result("hello"))
assert html.strip() == "<pre>hello</pre>"

def test_text_only_list_unchanged(self):
html = format_tool_result_content_raw(
_result([{"type": "text", "text": "plain"}])
)
assert html.strip() == "<pre>plain</pre>"

def test_empty_list_does_not_crash(self):
html = format_tool_result_content_raw(_result([]))
# No structured items, no text → empty <pre>, but no exception.
assert "<pre>" in html


class TestMarkdownStructuredContent:
def setup_method(self):
self.r = MarkdownRenderer()

def test_tool_reference_renders_json_block(self):
out = self.r.format_ToolResultContent(
_result([_TOOL_REFERENCE]), _md_message(_result([_TOOL_REFERENCE]))
)
assert out.strip()
assert "mcp__plugin_clmail__terminal" in out
assert "```json" in out

def test_text_item_renders_as_text(self):
output = _result([{"type": "text", "text": "hi there"}, _TOOL_REFERENCE])
out = self.r.format_ToolResultContent(output, _md_message(output))
assert "hi there" in out
# text is not embedded inside the json block
assert out.index("hi there") < out.index("```json")
assert "mcp__plugin_clmail__terminal" in out

def test_is_error_structured_renders_json_block(self):
"""Parity with HTML: typed items render even when is_error."""
output = _result([_TOOL_REFERENCE], is_error=True)
out = self.r.format_ToolResultContent(output, _md_message(output))
assert "```json" in out
assert "mcp__plugin_clmail__terminal" in out

def test_string_content_unchanged(self):
output = _result("plain string")
out = self.r.format_ToolResultContent(output, _md_message(output))
assert "plain string" in out

def test_todowrite_special_case_unchanged(self):
output = _result("Todos updated")
out = self.r.format_ToolResultContent(
output, _md_message(output, tool_name="TodoWrite")
)
assert out == "Todos updated"
Loading