From a713e17261dc41c863944572293b3efcfc992402 Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Sun, 17 May 2026 00:28:44 +0300 Subject: [PATCH] fix(card): strip envelope from tool_result text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude Code's Bash hook (or any other tool hook) rejects a call — sleep > 20s, dangerous compound command, file outside permitted scope, etc. — the tool_result content in JSONL arrives wrapped in a literal ```` envelope. ``transcript_parser`` took the first 100 chars of that text as the error summary and shoved it into the card head, producing rows like ✗ Bash · Error: Blocked: sleep 25 followed by: cat /tmp/a3_setup.log tail -60. To wait for a conditi… · 00:21 Observed on tests-3 today (sandbox blocked the diagnostic ``sleep 25; cat /tmp/a3_setup.log`` command). The opening/closing tags are internal framing — leaking them to the chat is just noise. Fix: ``extract_tool_result_text`` now strips both halves of the envelope before returning. Two-string ``.replace`` (no regex) keeps it cheap on the hot path; passthrough early-exit when neither tag substring is present so normal results pay zero cost. +3 unit tests cover the string path, the list-of-blocks path, and the no-envelope passthrough. Co-Authored-By: Claude Opus 4.7 --- src/ccbot/transcript_format.py | 27 +++++++++++++++++++--- tests/ccbot/test_transcript_format.py | 32 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) 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: