diff --git a/claude_code_log/factories/task_notification_factory.py b/claude_code_log/factories/task_notification_factory.py index 1f57b6ff..4a0c9ca4 100644 --- a/claude_code_log/factories/task_notification_factory.py +++ b/claude_code_log/factories/task_notification_factory.py @@ -59,7 +59,12 @@ r"<(?Ptask-id|tool-use-id|status|summary)>(?P.*?)", re.DOTALL, ) -_RESULT_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) +# The free-text blocks: an agent's ```` (its Markdown answer) and a +# Monitor's ```` (one notification per line its command prints; no +# status, and the summary only names the monitor). Either can quote any +# tag, its own included, so a block runs to the *last* closing tag of its +# kind (greedy): a notification has one of each at most. +_FREE_TEXT_RE = re.compile(r"<(?Presult|event)>(?P.*)", re.DOTALL) _USAGE_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) # ``Full transcript available at: `` trailer outside the @@ -69,8 +74,13 @@ re.MULTILINE, ) -# Lines inside follow ``key: value`` shape, one per line. +# Inside , older Claude Code wrote ``key: value`` lines; current +# versions write child tags (``N…``). _USAGE_LINE_RE = re.compile(r"^\s*(\w+)\s*:\s*(.+?)\s*$", re.MULTILINE) +_USAGE_TAG_RE = re.compile(r"<(\w+)>\s*([^<]*?)\s*") +# The token count's name in each form. Other keys (a workflow's +# ``agent_count``, ``agents_done``, …) are not usage and are ignored. +_TOKEN_KEYS = ("total_tokens", "subagent_tokens") def has_task_notification(text: str) -> bool: @@ -79,25 +89,49 @@ def has_task_notification(text: str) -> bool: def _parse_usage(body: str) -> TaskNotificationUsage: - """Parse the ``key: value`` lines inside a ```` block. + """Parse a ```` block, in either form. - Unknown keys are ignored; values that don't parse as ``int`` are - skipped (each known field is integer-shaped in practice). + Child tags (current) take the place of ``key: value`` lines (older); + the token count is ``subagent_tokens`` in the one and + ``total_tokens`` in the other. Unknown keys are ignored; values that + don't parse as ``int`` are skipped (each known field is + integer-shaped in practice). """ + pairs = _USAGE_TAG_RE.findall(body) or [ + (m.group(1), m.group(2)) for m in _USAGE_LINE_RE.finditer(body) + ] fields: dict[str, int] = {} - for match in _USAGE_LINE_RE.finditer(body): - key = match.group(1) + for key, value in pairs: try: - fields[key] = int(match.group(2)) + fields[key] = int(value) except ValueError: continue + tokens = next((fields[k] for k in _TOKEN_KEYS if k in fields), None) return TaskNotificationUsage( - total_tokens=fields.get("total_tokens"), + total_tokens=tokens, tool_uses=fields.get("tool_uses"), duration_ms=fields.get("duration_ms"), ) +def _take_free_text(body: str) -> tuple[dict[str, str], str]: + """``({tag: text}, rest)``: the ```` and ```` blocks, + each taken out where it first opens, and the body without them. + + A result is Markdown, trimmed; an event is the command's output, kept + verbatim (its indentation is part of it). + """ + taken: dict[str, str] = {} + rest = body + while (match := _FREE_TEXT_RE.search(rest)) and match.group("tag") not in taken: + text = match.group("body") + taken[match.group("tag")] = ( + text.strip() if match.group("tag") == "result" else text + ) + rest = rest[: match.start()] + rest[match.end() :] + return taken, rest + + def create_task_notification_message( meta: MessageMeta, text: str ) -> Optional[TaskNotificationMessage]: @@ -115,24 +149,23 @@ def create_task_notification_message( body = notification.group("body") - # Extract ```` and ```` first, then strip their full - # match text from the search surface before scanning for the - # single-tag header fields. ```` bodies are agent-authored - # markdown and frequently contain literal HTML/XML — a - # ``demo`` snippet inside ```` would - # otherwise clobber the real notification ```` field - # (and similarly for ```` / ````), poisoning the - # downstream fold/dedup path. - result_match = _RESULT_RE.search(body) - result_text = result_match.group("body") if result_match else "" - - usage_match = _USAGE_RE.search(body) + # Take the free-text blocks out first, then search what is left for + # ```` and the single-tag header fields. ```` bodies are + # agent-authored markdown and frequently contain literal HTML/XML, and + # an ```` is whatever the monitored command printed — a + # ``demo`` (or a ````) inside either would + # otherwise clobber the real field, poisoning the downstream + # fold/dedup path. Whichever block opens first is the real one; a + # second tag of the same kind is part of its text, never a field. + free_text, rest = _take_free_text(body) + result_text = free_text.get("result", "") + + usage_match = _USAGE_RE.search(rest) usage = _parse_usage(usage_match.group("body")) if usage_match else None - header_body = body - for block_match in (result_match, usage_match): - if block_match is not None: - header_body = header_body.replace(block_match.group(0), "", 1) + header_body = rest + if usage_match is not None: + header_body = header_body.replace(usage_match.group(0), "", 1) fields: dict[str, str] = {} for match in _FIELD_RE.finditer(header_body): @@ -153,6 +186,7 @@ def create_task_notification_message( task_id=fields.get("task-id", ""), status=fields.get("status", ""), summary=fields.get("summary", ""), + event=free_text.get("event", ""), result_text=result_text, usage=usage, transcript_path=transcript_path, diff --git a/claude_code_log/html/async_formatter.py b/claude_code_log/html/async_formatter.py index 180b0538..0b9da87b 100644 --- a/claude_code_log/html/async_formatter.py +++ b/claude_code_log/html/async_formatter.py @@ -35,6 +35,13 @@ def _code(value: str) -> str: return f"{escape_html(value)}" +def _event_html(event: str) -> str: + """A Monitor event's text: inline code for one line, a block for more.""" + if "\n" in event: + return f"
{escape_html(event)}
" + return _code(event) + + def _format_usage_rows(usage: Optional[TaskNotificationUsage]) -> list[str]: """Optional usage stats as `
/
` rows. Skips fields the parser didn't capture so older transcripts (or partial completions) don't @@ -166,6 +173,8 @@ def format_task_notification_content( f"{escape_html(content.status)}", ) ) + if content.event: + rows.append(_row("Event", _event_html(content.event))) rows.extend(_format_usage_rows(content.usage)) if content.transcript_path: rows.append(_row("Transcript", _code(content.transcript_path))) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 53cef5a0..e81ce4a9 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -1611,6 +1611,12 @@ def format_TaskNotificationMessage( lines.append(f"- **Task ID:** `{content.task_id}`") if content.status: lines.append(f"- **Status:** `{content.status}`") + event_block = "" + if content.event and "\n" in content.event: + lines.append("- **Event:**") + event_block = self._code_fence(content.event) + elif content.event: + lines.append(f"- **Event:** {_inline_code(content.event)}") usage = content.usage if usage is not None: if usage.total_tokens is not None: @@ -1626,6 +1632,8 @@ def format_TaskNotificationMessage( f"- **Spawn:** ↱ Task `#d-{content.spawning_task_message_index}`" ) head = "\n".join(lines) + if event_block: + head = f"{head}\n\n{event_block}" if content.result_text and not content.result_is_duplicate: body = self._collapsible("Result", content.result_text) return f"{head}\n\n{body}" if head else body diff --git a/claude_code_log/models.py b/claude_code_log/models.py index cb00175f..815c1e67 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1066,6 +1066,10 @@ class TaskNotificationMessage(MessageContent): Full transcript available at: /tmp/.../tasks/a8b740b.output + (Current Claude Code writes ```` as child tags instead — + ``……`` — and a Monitor's + notifications carry an ````; see ``task_notification_factory``.) + The ``result`` body usually duplicates the last sub-assistant message in the spawned agent's sidechain — Phase 3 of the async-agents plan folds it into the spawning Task's tool_result so @@ -1075,6 +1079,9 @@ class TaskNotificationMessage(MessageContent): task_id: str = "" status: str = "" summary: str = "" + # A Monitor event's text (the line its command printed); empty for + # any other notification. + event: str = "" result_text: str = "" usage: Optional[TaskNotificationUsage] = None transcript_path: Optional[str] = None diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 9657318b..a4e302c0 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -371,6 +371,7 @@ class TaskNotificationMessage(MessageContent): task_id: str status: str # "completed", "failed", … summary: str # Agent "" completed + event: str # a Monitor event's text ("" otherwise) result_text: str # the agent's final answer body usage: Optional[TaskNotificationUsage] transcript_path: Optional[str] # "Full transcript available at: …" @@ -380,6 +381,23 @@ class TaskNotificationMessage(MessageContent): spawning_task_message_index: Optional[int] = None ``` +`` comes in two forms, and `_parse_usage` reads both: older +Claude Code wrote `key: value` lines (`total_tokens: N` …), current +versions write child tags (`N +NN`; a few carry +`` instead, and a workflow adds counters such as +``, which are ignored). Both token names land in +`TaskNotificationUsage.total_tokens`. A Monitor sends one notification +per line its command prints, with no status, the line in `` and a +summary that only names the monitor; the card shows that text as an +*Event* row (a code block when it spans lines). `` and `` are free +text (a command's output, an agent's Markdown) and can quote any tag, so +the parser takes them out first, each where it first opens and running +to the last closing tag of its kind (a notification has one of each, so +a quoted inner tag is text), and only then searches the rest for +`` and the header fields. A result is trimmed; an event is kept +verbatim, indentation included. + The `result_text` is the canonical source for the spawn-fold — `_link_async_notifications` copies it onto `TaskOutput.async_final_answer` of the spawning `Task` tool_result so diff --git a/test/test_async_agents.py b/test/test_async_agents.py index 1fbd2366..51056f7b 100644 --- a/test/test_async_agents.py +++ b/test/test_async_agents.py @@ -215,6 +215,196 @@ def test_partial_usage_block_keeps_known_fields(self) -> None: ) +# --------------------------------------------------------------------------- +# in its XML form, and monitor s +# --------------------------------------------------------------------------- + + +def _notification(inner: str) -> str: + return f"\nt1\n{inner}\n" + + +class TestNotificationUsageAndEvents: + """Current Claude Code writes ```` as child tags, not the older + ``key: value`` lines; and a Monitor's events carry their text in + ````. Both were dropped silently before.""" + + def test_xml_usage_with_subagent_tokens(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "completed\n" + "104863" + "30351871" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=104863, tool_uses=30, duration_ms=351871 + ) + + def test_xml_usage_with_total_tokens_and_extra_counters(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "5003" + "12004" + "4" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=500, tool_uses=3, duration_ms=1200 + ) + + def test_legacy_usage_lines_still_parse(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "total_tokens: 7\ntool_uses: 1\nduration_ms: 9" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=7, tool_uses=1, duration_ms=9 + ) + + def test_monitor_event_text_is_kept(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + 'Monitor event: "CI run"\n' + "step 3 passed" + ), + ) + assert msg is not None + assert msg.event == "step 3 passed" + assert msg.summary == 'Monitor event: "CI run"' + + def test_an_event_quoting_result_or_usage_tags_stays_intact(self) -> None: + """An event is whatever the monitored command printed: tags in it + are its text, not the notification's own blocks.""" + printed = "log: ok 9" + msg = create_task_notification_message( + _meta(), + _notification( + f'Monitor event: "log"\n{printed}' + ), + ) + assert msg is not None + assert msg.event == printed + assert msg.result_text == "" + assert msg.usage is None + + def test_an_event_quoting_header_tags_does_not_set_them(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification("saw failed in the log"), + ) + assert msg is not None + assert msg.status == "" + assert msg.event == "saw failed in the log" + + def test_a_result_quoting_an_event_tag_is_not_an_event(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "completed\n" + "Monitors print a line each." + ), + ) + assert msg is not None + assert msg.event == "" + assert msg.result_text == "Monitors print a line each." + + def test_an_event_quoting_its_own_tag_stays_whole(self) -> None: + """The real block is the only one of its kind, so it closes at the + last closing tag: a quoted inner one is text.""" + printed = "a x then failed" + msg = create_task_notification_message( + _meta(), + _notification(f"completed\n{printed}"), + ) + assert msg is not None + assert msg.event == printed + assert msg.status == "completed" + + def test_a_result_quoting_its_own_closing_tag_stays_whole(self) -> None: + answer = "Wrap it as … and you're done." + msg = create_task_notification_message( + _meta(), + _notification(f"completed\n{answer}"), + ) + assert msg is not None + assert msg.result_text == answer + + def test_an_event_keeps_its_whitespace(self) -> None: + """An event is the command's output, verbatim: indentation and + trailing spaces are part of it.""" + msg = create_task_notification_message( + _meta(), _notification(" indented line ") + ) + assert msg is not None + assert msg.event == " indented line " + + def test_a_real_result_and_usage_beside_an_event_still_parse(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "line\nDone.\n" + "5" + ), + ) + assert msg is not None + assert (msg.event, msg.result_text) == ("line", "Done.") + assert msg.usage is not None and msg.usage.total_tokens == 5 + + def test_html_card_shows_usage_and_event(self) -> None: + from claude_code_log.html.async_formatter import ( + format_task_notification_content, + ) + + one_line = create_task_notification_message( + _meta(), + _notification( + "exit 1\n" + "1234" + ), + ) + assert one_line is not None + html = format_task_notification_content(one_line) + assert "
Event
exit <b>1</b>
" in html + assert "
Tokens
1,234
" in html + + several = create_task_notification_message( + _meta(), _notification("line one\nline two") + ) + assert several is not None + assert "
line one\nline two
" in ( + format_task_notification_content(several) + ) + + def test_markdown_card_shows_the_event(self) -> None: + from typing import Any, cast + + from claude_code_log.markdown.renderer import MarkdownRenderer + + renderer = MarkdownRenderer() + one_line = create_task_notification_message( + _meta(), _notification("step 3 passed") + ) + several = create_task_notification_message( + _meta(), _notification("line one\nline two") + ) + assert one_line is not None and several is not None + assert ( + "- **Event:** `step 3 passed`" + in renderer.format_TaskNotificationMessage(one_line, cast(Any, None)) + ) + md = renderer.format_TaskNotificationMessage(several, cast(Any, None)) + assert "line one\nline two" in md and "```" in md + + # --------------------------------------------------------------------------- # parse_taskoutput_output # ---------------------------------------------------------------------------