diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index a82c65ff..b0680a88 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -1275,6 +1275,23 @@ def _json_result_table_html(raw_content: str) -> Optional[str]: return f"
{full_html}" 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"
@@ -1363,6 +1393,15 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"""
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"{full_html}" 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:
diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py
index 5a865319..d3c10565 100644
--- a/claude_code_log/markdown/renderer.py
+++ b/claude_code_log/markdown/renderer.py
@@ -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)
diff --git a/test/test_generic_tool_result.py b/test/test_generic_tool_result.py
new file mode 100644
index 00000000..828fdee4
--- /dev/null
+++ b/test/test_generic_tool_result.py
@@ -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 .
+ assert html.strip() != ""
+
+ 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": ""}])
+ )
+ assert "