diff --git a/src/ccbot/transcript_format.py b/src/ccbot/transcript_format.py index 8fbc7a19..61cc4a6e 100644 --- a/src/ccbot/transcript_format.py +++ b/src/ccbot/transcript_format.py @@ -135,10 +135,31 @@ def format_tool_use_summary(name: str, input_data: dict[str, Any] | Any) -> str: return f"**{name}**" +# Claude Code wraps blocked / hook-rejected tool calls in a literal +# ```` envelope inside the JSONL +# tool_result content. The tags are an internal framing artefact — +# rendering them verbatim ("Bash · Error: Blocked: …") +# leaks implementation detail into the card head. Strip both halves +# wherever they appear; the inner message is what the user wants. +_TOOL_USE_ERROR_OPEN = "" +_TOOL_USE_ERROR_CLOSE = "" + + +def _strip_tool_use_error_envelope(text: str) -> str: + if _TOOL_USE_ERROR_OPEN not in text and _TOOL_USE_ERROR_CLOSE not in text: + return text + return text.replace(_TOOL_USE_ERROR_OPEN, "").replace(_TOOL_USE_ERROR_CLOSE, "") + + def extract_tool_result_text(content: list[Any] | Any) -> str: - """Concatenate all text fragments from a tool_result content block.""" + """Concatenate all text fragments from a tool_result content block. + + Strips the ```` envelope Claude Code + uses to wrap hook-rejected tool calls — those tags would otherwise + end up in the card head verbatim. + """ if isinstance(content, str): - return content + return _strip_tool_use_error_envelope(content) if isinstance(content, list): parts: list[str] = [] for item in content: @@ -148,7 +169,7 @@ def extract_tool_result_text(content: list[Any] | Any) -> str: parts.append(t) elif isinstance(item, str): parts.append(item) - return "\n".join(parts) + return _strip_tool_use_error_envelope("\n".join(parts)) return "" diff --git a/tests/ccbot/test_transcript_format.py b/tests/ccbot/test_transcript_format.py index 7598917e..0552d453 100644 --- a/tests/ccbot/test_transcript_format.py +++ b/tests/ccbot/test_transcript_format.py @@ -54,6 +54,38 @@ def test_skips_non_text_blocks(self) -> None: ] assert transcript_format.extract_tool_result_text(content) == "a\nb" + def test_strips_tool_use_error_envelope_string(self) -> None: + # Claude Code wraps hook-rejected results in + # ``...``. Without stripping, + # the literal tags ended up in card heads like + # "Bash · Error: Blocked: sleep 25 ...". + wrapped = ( + "Blocked: sleep 25 followed by: cat /tmp/x. " + "To wait for a condition, use ..." + ) + result = transcript_format.extract_tool_result_text(wrapped) + assert "" not in result + assert "" not in result + assert result.startswith("Blocked: sleep 25") + + def test_strips_tool_use_error_envelope_in_list(self) -> None: + content = [ + { + "type": "text", + "text": ("Blocked: rm -rf /"), + } + ] + result = transcript_format.extract_tool_result_text(content) + assert "" not in result + assert result == "Blocked: rm -rf /" + + def test_no_envelope_is_passthrough(self) -> None: + # Make sure the strip path doesn't touch normal output. + assert ( + transcript_format.extract_tool_result_text("normal output") + == "normal output" + ) + class TestExtractToolResultImages: def test_no_images_returns_none(self) -> None: