Skip to content
Open
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
71 changes: 66 additions & 5 deletions src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,53 @@ def format_tool_stats(tool_counts):
return " · ".join(parts)


TASK_NOTIFICATION_TAG = "<task-notification>"


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}>(.*?)</{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 <summary> 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", [])
Expand All @@ -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"
Expand All @@ -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); }
Expand Down
10 changes: 10 additions & 0 deletions src/claude_code_transcripts/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,13 @@
{% macro index_long_text(rendered_content) %}
<div class="index-item-long-text"><div class="truncatable"><div class="truncatable-content"><div class="index-item-long-text-content">{{ rendered_content|safe }}</div></div><button class="expand-btn">Show more</button></div></div>
{%- 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) %}
<div class="task-notification-body">
{%- if status %}<span class="task-status task-status-{{ status }}">{{ status }}</span>{% endif -%}
{%- if summary_html %}<span class="task-summary">{{ summary_html|safe }}</span>{% endif -%}
{%- if task_id %}<div class="task-meta">task <code>{{ task_id }}</code>{% if tool_use_id %} &middot; tool <code>{{ tool_use_id }}</code>{% endif %}</div>{% endif -%}
{%- if result_html %}<pre class="task-result">{{ result_html|safe }}</pre>{% endif -%}
</div>
{%- endmacro %}
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
89 changes: 89 additions & 0 deletions tests/test_generate_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <task-notification> -- 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 = (
"<task-notification>\n"
"<task-id>bash_AAA</task-id>\n"
"<tool-use-id>toolu_AAA</tool-use-id>\n"
"<status>completed</status>\n"
'<summary>Background command "Long task" completed (exit code 0)</summary>\n'
"</task-notification>"
)

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 <task-notification> 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 "&lt;task-id&gt;" 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 = "<task-notification><status>done</notification-task>"
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 = (
"<task-notification><task-id>x</task-id><status>failed</status>"
"<summary><script>alert(1)</script></summary></task-notification>"
)
html_out = render_message(
"user", json.dumps({"content": payload}), "2026-01-01T10:00:31.000Z"
)

assert "<script>" not in html_out