Skip to content
Merged
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
27 changes: 24 additions & 3 deletions src/ccbot/transcript_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ``<tool_use_error>…</tool_use_error>`` envelope inside the JSONL
# tool_result content. The tags are an internal framing artefact —
# rendering them verbatim ("Bash · Error: <tool_use_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>"
_TOOL_USE_ERROR_CLOSE = "</tool_use_error>"


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 ``<tool_use_error>…</tool_use_error>`` 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:
Expand All @@ -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 ""


Expand Down
32 changes: 32 additions & 0 deletions tests/ccbot/test_transcript_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ``<tool_use_error>...</tool_use_error>``. Without stripping,
# the literal tags ended up in card heads like
# "Bash · Error: <tool_use_error>Blocked: sleep 25 ...".
wrapped = (
"<tool_use_error>Blocked: sleep 25 followed by: cat /tmp/x. "
"To wait for a condition, use ...</tool_use_error>"
)
result = transcript_format.extract_tool_result_text(wrapped)
assert "<tool_use_error>" not in result
assert "</tool_use_error>" not in result
assert result.startswith("Blocked: sleep 25")

def test_strips_tool_use_error_envelope_in_list(self) -> None:
content = [
{
"type": "text",
"text": ("<tool_use_error>Blocked: rm -rf /</tool_use_error>"),
}
]
result = transcript_format.extract_tool_result_text(content)
assert "<tool_use_error>" 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:
Expand Down
Loading