From fa942733eed5e987f47b2ae3329d0c54460043c1 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 Date: Sun, 9 Aug 2026 10:00:06 +0530 Subject: [PATCH] Render background task completions as their own panel, not user prompts A tool started with run_in_background=true reports back as a `type: user` entry whose message.content is a plain XML string starting with -- not the usual list of tool_result blocks. Since is_tool_result_message() only recognises the list form, these fell through to the "User" branch, and a long session read as though the human had been typing system-generated XML at intervals. They now classify as a "Background task" panel showing the status, the summary, and the task and tool-use ids that tie the entry back to the tool_use that started it. Parsed with a regex rather than an XML parser: carries free text which may itself contain markup, so the payload is not guaranteed well-formed. A payload that yields no fields still renders as a notification with its raw text -- falling back to the user branch would reintroduce exactly the bug being fixed. Fields are escaped on the way in. The four snapshot updates are the added CSS only; no rendered message changed. Closes #99 --- src/claude_code_transcripts/__init__.py | 71 +++++++++++++-- .../templates/macros.html | 10 +++ ...enerateHtml.test_generates_index_html.html | 8 ++ ...rateHtml.test_generates_page_001_html.html | 8 ++ ...rateHtml.test_generates_page_002_html.html | 8 ++ ...SessionFile.test_jsonl_generates_html.html | 8 ++ tests/test_generate_html.py | 89 +++++++++++++++++++ 7 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3b..0bb7f24b 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -933,6 +933,53 @@ def format_tool_stats(tool_counts): return " ยท ".join(parts) +TASK_NOTIFICATION_TAG = "" + + +def is_task_notification(message_data): + """Whether this `user` entry is a background-task completion rather than a prompt. + + A tool started with run_in_background=true reports back as a `type: user` entry whose + content is a plain string of XML, not the usual list of tool_result blocks -- so + is_tool_result_message() does not recognise it and it was rendered as if the human + had typed it. + """ + content = message_data.get("content", "") + return isinstance(content, str) and content.lstrip().startswith( + TASK_NOTIFICATION_TAG + ) + + +def _task_notification_field(text, name): + match = re.search(rf"<{name}>(.*?)", text, re.DOTALL) + return match.group(1).strip() if match else "" + + +def render_task_notification(message_data): + """Render the notification's fields, falling back to the raw text if it is malformed. + + Parsed with a regex rather than an XML parser because the payload is not guaranteed + well-formed -- a carries free text, which may itself contain markup. The + fallback still renders as a notification: a malformed one must not end up looking + like a human message again, which is the bug being fixed. + """ + content = message_data.get("content", "").lstrip() + fields = { + name: _task_notification_field(content, name) + for name in ("task-id", "tool-use-id", "status", "summary", "result") + } + if not any(fields.values()): + return _macros.task_notification("", "", "", html.escape(content), "") + + return _macros.task_notification( + fields["task-id"], + fields["tool-use-id"], + fields["status"], + html.escape(fields["summary"]), + html.escape(fields["result"]), + ) + + def is_tool_result_message(message_data): """Check if a message contains only tool_result blocks.""" content = message_data.get("content", []) @@ -954,12 +1001,18 @@ def render_message(log_type, message_json, timestamp): except json.JSONDecodeError: return "" if log_type == "user": - content_html = render_user_message_content(message_data) - # Check if this is a tool result message - if is_tool_result_message(message_data): - role_class, role_label = "tool-reply", "Tool reply" + # A background task reporting back is not a prompt, so it is classified before + # the content is rendered as one. + if is_task_notification(message_data): + content_html = render_task_notification(message_data) + role_class, role_label = "task-notification", "Background task" else: - role_class, role_label = "user", "User" + content_html = render_user_message_content(message_data) + # Check if this is a tool result message + if is_tool_result_message(message_data): + role_class, role_label = "tool-reply", "Tool reply" + else: + role_class, role_label = "user", "User" elif log_type == "assistant": content_html = render_assistant_message(message_data) role_class, role_label = "assistant", "Assistant" @@ -983,6 +1036,14 @@ def render_message(log_type, message_json, timestamp): .message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } .message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } .message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.message.task-notification { background: #eceff1; border-left: 4px solid #607d8b; } +.task-notification .role-label { color: #455a64; } +.task-notification-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 0.9rem; } +.task-status { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 10px; background: #cfd8dc; color: #37474f; } +.task-status-completed { background: #c8e6c9; color: #1b5e20; } +.task-status-failed { background: #ffcdd2; color: #b71c1c; } +.task-meta { flex-basis: 100%; color: var(--text-muted); font-size: 0.8rem; } +.task-result { flex-basis: 100%; margin: 4px 0 0 0; } .tool-reply .role-label { color: #e65100; } .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 06018d31..278d3dc1 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -190,3 +190,13 @@ {% macro index_long_text(rendered_content) %}
{{ rendered_content|safe }}
{%- endmacro %} + +{# Background task completion. summary_html/result_html are pre-escaped so need |safe #} +{% macro task_notification(task_id, tool_use_id, status, summary_html, result_html) %} +
+{%- if status %}{{ status }}{% endif -%} +{%- if summary_html %}{{ summary_html|safe }}{% endif -%} +{%- if task_id %}
task {{ task_id }}{% if tool_use_id %} · tool {{ tool_use_id }}{% endif %}
{% endif -%} +{%- if result_html %}
{{ result_html|safe }}
{% endif -%} +
+{%- endmacro %} diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 693c48ff..66fe2fd3 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -16,6 +16,14 @@ .message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } .message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } .message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.message.task-notification { background: #eceff1; border-left: 4px solid #607d8b; } +.task-notification .role-label { color: #455a64; } +.task-notification-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 0.9rem; } +.task-status { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 10px; background: #cfd8dc; color: #37474f; } +.task-status-completed { background: #c8e6c9; color: #1b5e20; } +.task-status-failed { background: #ffcdd2; color: #b71c1c; } +.task-meta { flex-basis: 100%; color: var(--text-muted); font-size: 0.8rem; } +.task-result { flex-basis: 100%; margin: 4px 0 0 0; } .tool-reply .role-label { color: #e65100; } .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index cdc794bd..a0f3a39b 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -16,6 +16,14 @@ .message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } .message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } .message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.message.task-notification { background: #eceff1; border-left: 4px solid #607d8b; } +.task-notification .role-label { color: #455a64; } +.task-notification-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 0.9rem; } +.task-status { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 10px; background: #cfd8dc; color: #37474f; } +.task-status-completed { background: #c8e6c9; color: #1b5e20; } +.task-status-failed { background: #ffcdd2; color: #b71c1c; } +.task-meta { flex-basis: 100%; color: var(--text-muted); font-size: 0.8rem; } +.task-result { flex-basis: 100%; margin: 4px 0 0 0; } .tool-reply .role-label { color: #e65100; } .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 2d46a78b..4311224d 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -16,6 +16,14 @@ .message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } .message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } .message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.message.task-notification { background: #eceff1; border-left: 4px solid #607d8b; } +.task-notification .role-label { color: #455a64; } +.task-notification-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 0.9rem; } +.task-status { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 10px; background: #cfd8dc; color: #37474f; } +.task-status-completed { background: #c8e6c9; color: #1b5e20; } +.task-status-failed { background: #ffcdd2; color: #b71c1c; } +.task-meta { flex-basis: 100%; color: var(--text-muted); font-size: 0.8rem; } +.task-result { flex-basis: 100%; margin: 4px 0 0 0; } .tool-reply .role-label { color: #e65100; } .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index e83424ab..b418797f 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -16,6 +16,14 @@ .message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } .message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } .message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.message.task-notification { background: #eceff1; border-left: 4px solid #607d8b; } +.task-notification .role-label { color: #455a64; } +.task-notification-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 0.9rem; } +.task-status { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 10px; background: #cfd8dc; color: #37474f; } +.task-status-completed { background: #c8e6c9; color: #1b5e20; } +.task-status-failed { background: #ffcdd2; color: #b71c1c; } +.task-meta { flex-basis: 100%; color: var(--text-muted); font-size: 0.8rem; } +.task-result { flex-basis: 100%; margin: 4px 0 0 0; } .tool-reply .role-label { color: #e65100; } .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 25c28224..4e1af08d 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -21,6 +21,8 @@ analyze_conversation, format_tool_stats, is_tool_result_message, + is_task_notification, + render_message, inject_gist_preview_js, create_gist, GIST_PREVIEW_JS, @@ -1638,3 +1640,90 @@ def test_search_total_pages_available(self, output_dir): # Total pages should be embedded for JS to know how many pages to fetch assert "totalPages" in index_html or "total_pages" in index_html + + +class TestTaskNotifications: + """Background task completions arriving as `type: user` entries. + + When a tool runs with run_in_background=true, the completion comes back as a user + entry whose message.content is a *string* starting with -- not + the usual list of tool_result blocks. is_tool_result_message() only recognises the + list form, so these were labelled "User" in a blue prompt panel, and a long session + read as though the human had typed system XML at intervals (issue #99). + """ + + NOTIFICATION = ( + "\n" + "bash_AAA\n" + "toolu_AAA\n" + "completed\n" + 'Background command "Long task" completed (exit code 0)\n' + "" + ) + + def test_a_notification_is_recognised(self): + assert is_task_notification({"content": self.NOTIFICATION}) is True + + def test_a_real_user_prompt_is_not(self): + """The control. Only the string form starting with the tag counts.""" + assert is_task_notification({"content": "please fix the tests"}) is False + assert is_task_notification({"content": [{"type": "tool_result"}]}) is False + assert is_task_notification({"content": ""}) is False + assert ( + is_task_notification({"content": "I asked about it"}) + is False + ), "the tag has to start the string, not merely appear in it" + + def test_leading_whitespace_does_not_hide_it(self): + assert is_task_notification({"content": "\n " + self.NOTIFICATION}) is True + + def test_it_is_not_labelled_as_a_user_prompt(self): + html_out = render_message( + "user", + json.dumps({"content": self.NOTIFICATION}), + "2026-01-01T10:00:31.000Z", + ) + + assert 'class="message user"' not in html_out + assert ">User<" not in html_out + + def test_the_status_and_summary_are_shown_rather_than_raw_xml(self): + html_out = render_message( + "user", + json.dumps({"content": self.NOTIFICATION}), + "2026-01-01T10:00:31.000Z", + ) + + assert "completed" in html_out + assert "Long task" in html_out + assert "<task-id>" not in html_out, "raw XML is still on the page" + + def test_the_task_id_is_shown_so_it_can_be_tied_to_its_tool_use(self): + html_out = render_message( + "user", + json.dumps({"content": self.NOTIFICATION}), + "2026-01-01T10:00:31.000Z", + ) + + assert "bash_AAA" in html_out + + def test_unparseable_xml_still_renders_as_a_notification(self): + """A malformed notification must not fall back to looking like a human prompt.""" + broken = "done" + html_out = render_message( + "user", json.dumps({"content": broken}), "2026-01-01T10:00:31.000Z" + ) + + assert 'class="message user"' not in html_out + assert html_out.strip(), "the entry disappeared entirely" + + def test_html_inside_a_notification_is_escaped(self): + payload = ( + "xfailed" + "" + ) + html_out = render_message( + "user", json.dumps({"content": payload}), "2026-01-01T10:00:31.000Z" + ) + + assert "