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"
{_params_root_html(table_html)}
" +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"
{_json_dump_value_html(pretty)}
" + + def format_tool_result_content_raw(tool_result: ToolResultContent) -> str: """Format raw ToolResultContent as HTML (fallback formatter). @@ -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": @@ -1325,8 +1347,16 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str: f'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 XML tags but keep the content inside # Also strip redundant "String: ..." portions that echo the input @@ -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"
{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 "