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: