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
84 changes: 59 additions & 25 deletions claude_code_log/factories/task_notification_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@
r"<(?P<tag>task-id|tool-use-id|status|summary)>(?P<body>.*?)</(?P=tag)>",
re.DOTALL,
)
_RESULT_RE = re.compile(r"<result>\s*(?P<body>.*?)\s*</result>", re.DOTALL)
# The free-text blocks: an agent's ``<result>`` (its Markdown answer) and a
# Monitor's ``<event>`` (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"<(?P<tag>result|event)>(?P<body>.*)</(?P=tag)>", re.DOTALL)
_USAGE_RE = re.compile(r"<usage>\s*(?P<body>.*?)\s*</usage>", re.DOTALL)

# ``Full transcript available at: <path>`` trailer outside the
Expand All @@ -69,8 +74,13 @@
re.MULTILINE,
)

# Lines inside <usage> follow ``key: value`` shape, one per line.
# Inside <usage>, older Claude Code wrote ``key: value`` lines; current
# versions write child tags (``<subagent_tokens>N</subagent_tokens>…``).
_USAGE_LINE_RE = re.compile(r"^\s*(\w+)\s*:\s*(.+?)\s*$", re.MULTILINE)
_USAGE_TAG_RE = re.compile(r"<(\w+)>\s*([^<]*?)\s*</\1>")
# 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:
Expand All @@ -79,25 +89,49 @@ def has_task_notification(text: str) -> bool:


def _parse_usage(body: str) -> TaskNotificationUsage:
"""Parse the ``key: value`` lines inside a ``<usage>`` block.
"""Parse a ``<usage>`` 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 ``<result>`` and ``<event>`` 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]:
Expand All @@ -115,24 +149,23 @@ def create_task_notification_message(

body = notification.group("body")

# Extract ``<result>`` and ``<usage>`` first, then strip their full
# match text from the search surface before scanning for the
# single-tag header fields. ``<result>`` bodies are agent-authored
# markdown and frequently contain literal HTML/XML — a
# ``<summary>demo</summary>`` snippet inside ``<result>`` would
# otherwise clobber the real notification ``<summary>`` field
# (and similarly for ``<status>`` / ``<task-id>``), 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
# ``<usage>`` and the single-tag header fields. ``<result>`` bodies are
# agent-authored markdown and frequently contain literal HTML/XML, and
# an ``<event>`` is whatever the monitored command printed — a
# ``<summary>demo</summary>`` (or a ``<usage>``) 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):
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions claude_code_log/html/async_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ def _code(value: str) -> str:
return f"<code>{escape_html(value)}</code>"


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"<pre><code>{escape_html(event)}</code></pre>"
return _code(event)


def _format_usage_rows(usage: Optional[TaskNotificationUsage]) -> list[str]:
"""Optional usage stats as `<dt>/<dd>` rows. Skips fields the parser
didn't capture so older transcripts (or partial completions) don't
Expand Down Expand Up @@ -166,6 +173,8 @@ def format_task_notification_content(
f"{escape_html(content.status)}</span>",
)
)
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)))
Expand Down
8 changes: 8 additions & 0 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,10 @@ class TaskNotificationMessage(MessageContent):
</task-notification>
Full transcript available at: /tmp/.../tasks/a8b740b.output

(Current Claude Code writes ``<usage>`` as child tags instead —
``<subagent_tokens>…</subagent_tokens><tool_uses>…`` — and a Monitor's
notifications carry an ``<event>``; 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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions dev-docs/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ class TaskNotificationMessage(MessageContent):
task_id: str
status: str # "completed", "failed", …
summary: str # Agent "<description>" 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: …"
Expand All @@ -380,6 +381,23 @@ class TaskNotificationMessage(MessageContent):
spawning_task_message_index: Optional[int] = None
```

`<usage>` comes in two forms, and `_parse_usage` reads both: older
Claude Code wrote `key: value` lines (`total_tokens: N` …), current
versions write child tags (`<subagent_tokens>N</subagent_tokens>
<tool_uses>N</tool_uses><duration_ms>N</duration_ms>`; a few carry
`<total_tokens>` instead, and a workflow adds counters such as
`<agent_count>`, 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 `<event>` and a
summary that only names the monitor; the card shows that text as an
*Event* row (a code block when it spans lines). `<event>` and `<result>` 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
`<usage>` 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
Expand Down
Loading
Loading