From 19f08dc763ebb948c5dc1d2c3c6a504c734ae1cc Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 8 May 2026 01:08:20 +0200 Subject: [PATCH 1/2] Add specialised rendering for the built-in Monitor tool (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes addressing #142: ## 1. Monitor tool — typed input/output models, formatters, title The built-in ``Monitor`` tool (shell-stream watcher; ``description`` / ``command`` / ``timeout_ms`` / ``persistent``) used to fall through to the generic params-table render. Add full specialised rendering: - ``MonitorInput`` (Pydantic) and ``MonitorOutput`` (dataclass) on ``models.py``, registered in the ``ToolInput`` and ``ToolOutput`` unions. - Factory: ``"Monitor"`` registered in ``TOOL_INPUT_MODELS`` so ``ToolUseMessage.input`` deserialises into ``MonitorInput``; ``parse_monitor_output`` registered in ``TOOL_OUTPUT_PARSERS`` and pulls the task id out of the start-confirmation paragraph for optional cross-reference (the body text is what actually renders). - HTML formatter (``html/tool_formatters.py``): - Title: ``🔭 Monitor ``. - Input body: 4-row ``tool-params-table`` with ``description``, ``command``, ``timeout_ms``, ``persistent``. The ``command`` cell uses the existing ``render_collapsible_code`` helper when the body is multi-line / long, matching the line-count-badge affordance already used for other tool bodies; short single-line commands render in a plain ``
``.
  - Output body: start-confirmation paragraph verbatim inside a
    ``
``. - Markdown formatter mirrors the shape: bullet list of the three scalar fields, command in a fenced ``bash`` block, output text passes through. ## 2. Task-end → originating tool_use backlink When a ```` carries ```` pointing back at the originating tool_use (built-in Monitor's start-confirmation returns the task id; later, when the stream ends, Claude Code injects a notification carrying both the task id and the tool_use id), the notification's Task ID value should link back to the originating tool_use card. - ``task_notification_factory`` now extracts ```` from the notification block alongside ```` / ```` / ````. Older notifications without the field still parse — the new ``tool_use_id`` is just ``None``. - New renderer pass ``_link_tool_use_notifications`` (in ``renderer.py``, runs after ``_link_async_notifications``) builds a ``tool_use_id → message_index`` map and sets ``spawning_task_message_index`` on each notification carrying a matching id. Reuses the existing field name to keep the formatter single-shape — both flows now wire the same backlink slot. - HTML formatter: when ``spawning_task_message_index`` is set, the Task ID's ```` is wrapped in ````. The separate "Spawn" row that used to carry the backlink is removed — the Task ID value itself is the more discoverable affordance and matches the spec in #142. This unifies the async-agent and Monitor flows behind one rendering path. Async-agent notifications gain the same Task-ID-as-link shape, which is a small intentional improvement to that flow as well. ## Tests New ``test/test_monitor_rendering.py`` (20 cases): - ``TestMonitorInputModel`` / ``TestMonitorOutputParser`` — direct factory tests including the missing-task-id fallback. - ``TestMonitorHtmlFormatter`` — input grid, short-command-inline, long-command-collapsible, optional-fields-omitted, output paragraph. - ``TestMonitorFixtureRendering`` — drives the real renderers against a small JSONL fixture (Monitor call + result + matching task-notification). Asserts: telescope icon and description in the title, four-row grid, collapsible command with line-count badge, result paragraph, and (most importantly) the backlink — the Task ID ```` href matches the Monitor tool_use's ``msg-d-N`` anchor. - ``TestTaskNotificationToolUseIdBacklink`` — orthogonal to the Monitor flow: factory extracts ``tool-use-id``, legacy notifications without the field still parse, an orphan ``tool-use-id`` (no matching tool_use in the transcript) renders as plain ```` rather than an anchor pointing at nothing. Snapshot drift is one line in ``test_snapshot_html.ambr`` — exactly the async-agent fixture's notification card shifting from the Spawn row + plain-Task-ID shape to the Task-ID-as-link shape. ## CI - ``just test``: 1610 pass, 7 skipped (was 1591 before; +19 new) - ``just test-tui``: 66 pass - ``just test-browser``: 39 pass, 1 skipped - ruff + pyright clean on production files. --- .../factories/task_notification_factory.py | 9 +- claude_code_log/factories/tool_factory.py | 32 ++ claude_code_log/html/async_formatter.py | 29 +- claude_code_log/html/renderer.py | 16 + claude_code_log/html/tool_formatters.py | 80 ++++ claude_code_log/markdown/renderer.py | 28 ++ claude_code_log/models.py | 41 ++ claude_code_log/renderer.py | 49 +++ test/__snapshots__/test_snapshot_html.ambr | 2 +- test/test_data/monitor_tool.jsonl | 4 + test/test_monitor_rendering.py | 366 ++++++++++++++++++ 11 files changed, 640 insertions(+), 16 deletions(-) create mode 100644 test/test_data/monitor_tool.jsonl create mode 100644 test/test_monitor_rendering.py diff --git a/claude_code_log/factories/task_notification_factory.py b/claude_code_log/factories/task_notification_factory.py index 5ecb3fa5..1f57b6ff 100644 --- a/claude_code_log/factories/task_notification_factory.py +++ b/claude_code_log/factories/task_notification_factory.py @@ -50,9 +50,13 @@ re.DOTALL, ) -# Single-tag fields inside the notification. +# Single-tag fields inside the notification. ``tool-use-id`` is the +# originating ``toolu_...`` id from the spawning tool_use; surfaced here +# so the renderer can backlink the notification's Task ID value to the +# original tool_use card (#142). Older notifications didn't carry it, +# so the absence is benign — the dict get below returns None. _FIELD_RE = re.compile( - r"<(?Ptask-id|status|summary)>(?P.*?)", + r"<(?Ptask-id|tool-use-id|status|summary)>(?P.*?)", re.DOTALL, ) _RESULT_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) @@ -153,4 +157,5 @@ def create_task_notification_message( usage=usage, transcript_path=transcript_path, raw_text=text, + tool_use_id=fields.get("tool-use-id") or None, ) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 010a0d80..e30617bb 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -45,6 +45,7 @@ ToolUseContent, ToolUseMessage, ToolUseResult, + MonitorInput, SkillInput, WebSearchInput, WebFetchInput, @@ -55,6 +56,7 @@ BashOutput, EditOutput, ExitPlanModeOutput, + MonitorOutput, ReadOutput, SendMessageOutput, TaskCreateOutput, @@ -99,6 +101,7 @@ "ExitPlanMode": ExitPlanModeInput, "WebSearch": WebSearchInput, "WebFetch": WebFetchInput, + "Monitor": MonitorInput, "Skill": SkillInput, # Teammates feature tools "TeamCreate": TeamCreateInput, @@ -705,6 +708,34 @@ def parse_webfetch_output( return None +# Match ``Monitor started (task , …)`` so the task id is available +# to downstream consumers without re-parsing the full body. The id is +# the short alphanumeric form (e.g. ``b07h5t4ng``) the harness echoes +# back; not load-bearing for rendering today (the body text is shown +# verbatim) but useful for the task-end backlink (#142) and any future +# UI that wants to cross-reference the originating Monitor card. +_MONITOR_TASK_ID_RE = re.compile(r"Monitor started \(task ([A-Za-z0-9]+)") + + +def parse_monitor_output( + tool_result: ToolResultContent, + file_path: Optional[str], +) -> Optional[MonitorOutput]: + """Parse Monitor tool's start-confirmation result. + + The harness emits a single paragraph confirming the monitor was + armed and naming the task id. Capture the raw text for rendering + and extract the task id when the format matches; both fields are + optional from the renderer's perspective. + """ + del file_path # Unused — Monitor's result text is self-contained. + text = _extract_tool_result_text(tool_result).strip() + if not text: + return None + task_match = _MONITOR_TASK_ID_RE.search(text) + return MonitorOutput(text=text, task_id=task_match.group(1) if task_match else None) + + # ============================================================================= # Teammates feature tool output parsers # ============================================================================= @@ -1004,6 +1035,7 @@ def parse_taskoutput_output( "ExitPlanMode": parse_exitplanmode_output, "WebSearch": parse_websearch_output, "WebFetch": parse_webfetch_output, + "Monitor": parse_monitor_output, # Teammates feature tools "TeamCreate": parse_teamcreate_output, "TeamDelete": parse_teamdelete_output, diff --git a/claude_code_log/html/async_formatter.py b/claude_code_log/html/async_formatter.py index 036699a3..3b6782e6 100644 --- a/claude_code_log/html/async_formatter.py +++ b/claude_code_log/html/async_formatter.py @@ -129,7 +129,22 @@ def format_task_notification_content(content: TaskNotificationMessage) -> str: """ rows: list[str] = [] if content.task_id: - rows.append(_row("Task ID", _code(content.task_id))) + if content.spawning_task_message_index is not None: + # Backlink anchor format matches the rest of the renderer + # ("d-{N}" → "msg-d-{N}"; the template emits the corresponding + # id on every message div). Wrapping the Task ID itself in + # the anchor (rather than a separate "Spawn" row) keeps the + # affordance close to the value the reader is looking up + # — matches issue #142's spec for the Monitor backlink and + # is a cleaner shape for the agent-spawn case too. + anchor = f"msg-d-{content.spawning_task_message_index}" + task_id_html = ( + f"" + f"{escape_html(content.task_id)}" + ) + else: + task_id_html = _code(content.task_id) + rows.append(_row("Task ID", task_id_html)) if content.status: status_class = ( f"status-{content.status}" @@ -146,18 +161,6 @@ def format_task_notification_content(content: TaskNotificationMessage) -> str: rows.extend(_format_usage_rows(content.usage)) if content.transcript_path: rows.append(_row("Transcript", _code(content.transcript_path))) - if content.spawning_task_message_index is not None: - # Backlink anchor format matches the rest of the renderer - # ("d-{N}" → "msg-d-{N}"; the template emits the corresponding - # id on every message div). - anchor = f"msg-d-{content.spawning_task_message_index}" - rows.append( - _row( - "Spawn", - f"" - f"↱ Task", - ) - ) parts: list[str] = [] if rows: diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 8ff94a39..3c477577 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -53,6 +53,8 @@ SkillInput, WebSearchInput, WebFetchInput, + MonitorInput, + MonitorOutput, WriteInput, # Tool output types AskUserQuestionOutput, @@ -150,6 +152,8 @@ format_websearch_output, format_webfetch_input, format_webfetch_output, + format_monitor_input, + format_monitor_output, format_write_input, format_write_output, render_params_table, @@ -589,6 +593,14 @@ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> s """Format → collapsible markdown with metadata badge.""" return format_webfetch_output(output) + def format_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: + """Format → 4-row params table with collapsible command.""" + return format_monitor_input(input) + + def format_MonitorOutput(self, output: MonitorOutput, _: TemplateMessage) -> str: + """Format → start-confirmation paragraph verbatim.""" + return format_monitor_output(output) + # ------------------------------------------------------------------------- # Tool Input Title Methods (for Renderer.title_ToolUseMessage dispatch) # ------------------------------------------------------------------------- @@ -699,6 +711,10 @@ def title_WebFetchInput( """Title → '🌐 WebFetch '.""" return self._tool_title(message, "🌐", input.url) + def title_MonitorInput(self, input: MonitorInput, message: TemplateMessage) -> str: + """Title → '🔭 Monitor '.""" + return self._tool_title(message, "🔭", input.description) + def title_SkillInput(self, input: SkillInput, message: TemplateMessage) -> str: """Title → '💡 Skill '.""" return self._tool_title(message, "💡", input.skill) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 9b647d48..f4c83f7b 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -22,6 +22,7 @@ from .utils import ( escape_html, + render_collapsible_code, render_file_content_collapsible, render_markdown_collapsible, ) @@ -37,6 +38,8 @@ ExitPlanModeInput, ExitPlanModeOutput, GrepInput, + MonitorInput, + MonitorOutput, MultiEditInput, ReadInput, ReadOutput, @@ -680,6 +683,81 @@ def format_webfetch_output(output: WebFetchOutput) -> str: return f"{badge_html}{content_html}" +# -- Monitor Tool ------------------------------------------------------------- + + +def format_monitor_input(monitor_input: MonitorInput) -> str: + """Format Monitor tool use as a key-value grid. + + Renders four rows — ``description``, ``command``, ``timeout_ms``, + ``persistent`` — using the same ``tool-params-table`` shape as + other multi-field tool inputs. The ``command`` value (often a + multi-line bash poll-loop) renders inside a collapsible block so + a long script doesn't dominate the card. + + The ``description`` is shown both in the title and the body; the + body row anchors the rendering to the harness's exact field name + and keeps the card useful when a future title format changes. + """ + params: dict[str, Any] = {"description": monitor_input.description} + # Command rendered separately for adaptive collapsibility — pass + # a placeholder through the table builder, then replace with the + # specialised cell. This keeps the row layout consistent with + # ``render_params_table`` (same CSS hooks downstream) while letting + # us keep the line-count badge for the multi-line case. + command = monitor_input.command + line_count = command.count("\n") + 1 + escaped_command = escape_html(command) + if line_count > 5 or len(command) > 300: + # Use the collapsible-code helper for the same visual treatment + # other multi-line tool bodies get (line-count badge + preview). + preview_lines = "\n".join(command.splitlines()[:3]) + preview_html = f"
{escape_html(preview_lines)}
" + full_html = f"
{escaped_command}
" + command_cell = render_collapsible_code(preview_html, full_html, line_count) + else: + command_cell = f"
{escaped_command}
" + + if monitor_input.timeout_ms is not None: + params["timeout_ms"] = monitor_input.timeout_ms + if monitor_input.persistent is not None: + params["persistent"] = monitor_input.persistent + + # Compose the table by hand so the ``command`` row carries the + # specialised cell instead of a generic stringification. + rows: list[str] = [] + rows.append( + f"description" + f"{escape_html(monitor_input.description)}" + ) + rows.append( + f"command" + f"{command_cell}" + ) + if monitor_input.timeout_ms is not None: + rows.append( + f"timeout_ms" + f"{escape_html(str(monitor_input.timeout_ms))}" + ) + if monitor_input.persistent is not None: + rows.append( + f"persistent" + f"{escape_html(str(monitor_input.persistent))}" + ) + return f"{''.join(rows)}
" + + +def format_monitor_output(output: MonitorOutput) -> str: + """Format Monitor tool result — the start-confirmation paragraph. + + The harness emits a single paragraph confirming the monitor was + armed and naming the task id. Render verbatim inside a paragraph + block; the body is short enough that no collapsibility is worth + the chrome. + """ + return f"
{escape_html(output.text)}
" + + # -- Generic Parameter Table -------------------------------------------------- @@ -871,6 +949,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str: "format_grep_input", "format_websearch_input", "format_webfetch_input", + "format_monitor_input", # Tool output formatters (called by HtmlRenderer.format_{OutputClass}) "format_read_output", "format_write_output", @@ -881,6 +960,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str: "format_exitplanmode_output", "format_websearch_output", "format_webfetch_output", + "format_monitor_output", # Fallback for ToolResultContent "format_tool_result_content_raw", # Legacy formatters (still used) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 3e5229ad..f9f160af 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -61,6 +61,8 @@ SkillInput, WebSearchInput, WebFetchInput, + MonitorInput, + MonitorOutput, WriteInput, # Tool output types AskUserQuestionOutput, @@ -837,6 +839,28 @@ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str: return self._code_fence(input.prompt) return "" + def format_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: + """Format → bullet list of fields with the command in a fenced block. + + Mirrors the HTML 4-row grid in plain Markdown. The command's + adaptive fence width comes from ``_code_fence``; the description + is also in the title but kept here so the body stands alone. + """ + lines: list[str] = [f"- **description:** {input.description}"] + if input.timeout_ms is not None: + lines.append(f"- **timeout_ms:** {input.timeout_ms}") + if input.persistent is not None: + lines.append(f"- **persistent:** {input.persistent}") + lines.append("") + lines.append("**command:**") + lines.append("") + lines.append(self._code_fence(input.command, "bash")) + return "\n".join(lines) + + def format_MonitorOutput(self, output: MonitorOutput, _: TemplateMessage) -> str: + """Format → start-confirmation paragraph verbatim.""" + return output.text + def format_SkillInput(self, _input: SkillInput, _: TemplateMessage) -> str: """Format → '' (skill name in title; body folded in via skill_body).""" return "" @@ -1394,6 +1418,10 @@ def title_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str: url = input.url[:60] + "…" if len(input.url) > 60 else input.url return f"🌐 WebFetch `{url}`" + def title_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: + """Title → '🔭 Monitor '.""" + return f"🔭 Monitor {input.description}" + def title_SkillInput(self, input: SkillInput, _: TemplateMessage) -> str: """Title → '💡 Skill ``'.""" return f"💡 Skill `{input.skill}`" diff --git a/claude_code_log/models.py b/claude_code_log/models.py index b96d97bc..625fb4d5 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -723,6 +723,12 @@ class TaskNotificationMessage(MessageContent): usage: Optional[TaskNotificationUsage] = None transcript_path: Optional[str] = None raw_text: Optional[str] = None # Original content if parsing dropped fields + # ```` from the notification block — matches the + # originating tool_use's ``id`` (e.g. ``toolu_01...``). Used to emit + # a backlink from the notification card's Task ID value to the + # original tool_use card (#142 — Monitor tool task-end backlink). + # Optional because older notifications didn't carry this field. + tool_use_id: Optional[str] = None # Phase 3 dedup marker: when True, ``result_text`` duplicates the # last sub-assistant in the spawning Task's sidechain (which is # already rendered inline). The renderer should then collapse the @@ -1088,6 +1094,20 @@ class WebSearchInput(BaseModel): query: str +class MonitorInput(BaseModel): + """Input parameters for the built-in ``Monitor`` tool. + + Streams stdout from a long-running shell command, emitting one + notification per line. Used to watch CI checks, log tails, or + poll-loops that emit only on state change. + """ + + description: str + command: str + timeout_ms: Optional[int] = None + persistent: Optional[bool] = None + + class WebFetchInput(BaseModel): """Input parameters for the WebFetch tool.""" @@ -1197,6 +1217,7 @@ class TaskOutputInput(BaseModel): ExitPlanModeInput, WebSearchInput, WebFetchInput, + MonitorInput, SkillInput, TeamCreateInput, TeamDeleteInput, @@ -1398,6 +1419,25 @@ class ExitPlanModeOutput: approved: bool # Whether the plan was approved +@dataclass +class MonitorOutput: + """Parsed output for the built-in ``Monitor`` tool's start + confirmation. + + The result text is a single paragraph like ``Monitor started + (task , timeout ms). You will be notified on each event. + Keep working — do not poll or sleep. Events may arrive while + you are waiting for the user — an event is not their reply.`` + + We capture the raw text and (optionally) the parsed task id + when the format matches; the renderer uses the raw text by + default and the task id is currently informational only. + """ + + text: str + task_id: Optional[str] = None + + @dataclass class WebSearchLink: """Single search result link from WebSearch output.""" @@ -1552,6 +1592,7 @@ class TaskOutputResult: ExitPlanModeOutput, WebSearchOutput, WebFetchOutput, + MonitorOutput, TeamCreateOutput, TeamDeleteOutput, TaskCreateOutput, diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2ec66afe..ce54c815 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -859,6 +859,13 @@ def generate_template_messages( with log_timing("Link async notifications", t_start): _link_async_notifications(ctx, detail) + # Independent pass: link tool-use-id-bearing notifications (e.g. + # built-in Monitor task-end) back to their originating tool_use. + # Distinct from the agent-spawn flow above — there's no fold or + # dedup, just a backlink on the Task ID value (#142). + with log_timing("Link tool_use notifications", t_start): + _link_tool_use_notifications(ctx) + return root_messages, session_nav, ctx @@ -2271,6 +2278,48 @@ def _populate_task_metadata(ctx: RenderingContext) -> None: ) +def _link_tool_use_notifications(ctx: RenderingContext) -> None: + """Link ```` entries that carry ```` + back to the originating tool_use card (#142 — built-in ``Monitor``). + + Distinct from ``_link_async_notifications``: the agent-spawn flow + matches by ``agent_id`` and folds the duplicated answer onto the + spawning Task; this pass matches by ``tool_use_id`` directly and + only sets the backlink anchor — no fold, no dedup. The Monitor + notification's ```` field is empty by design (it carries + a single status summary, not a duplicate body), so there's nothing + to dedup against the original tool_use's result. + + Reuses ``spawning_task_message_index`` as the backlink slot to + keep the formatter logic single-shape — the field name reads as + "the linked tool_use card's index" in either flow. + """ + # Build tool_use_id → message_index map across all messages. + tool_use_index: dict[str, int] = {} + for tm in ctx.messages: + if tm.type == "tool_use" and tm.tool_use_id and tm.message_index is not None: + # First occurrence wins — multiple tool_uses sharing the same + # tool_use_id is malformed input; the backlink target should + # be the earliest, matching reading order. + tool_use_index.setdefault(tm.tool_use_id, tm.message_index) + if not tool_use_index: + return + + for tm in ctx.messages: + content = tm.content + if not isinstance(content, TaskNotificationMessage): + continue + if content.spawning_task_message_index is not None: + # Already linked by ``_link_async_notifications`` — don't + # overwrite (the agent-spawn link is a stronger signal). + continue + if not content.tool_use_id: + continue + target_idx = tool_use_index.get(content.tool_use_id) + if target_idx is not None: + content.spawning_task_message_index = target_idx + + def _link_async_notifications( ctx: RenderingContext, detail: DetailLevel = DetailLevel.FULL ) -> None: diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index e035756a..236e1e48 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4057,7 +4057,7 @@
11111111-000 → 11111111-000
-
Task ID
cccc333
Status
completed
Tokens
23,099
Tool uses
2
Duration
15.5s
Transcript
/tmp/claude-1000/synthetic/tasks/cccc333.output
Spawn
↱ Task
+
Task ID
cccc333
Status
completed
Tokens
23,099
Tool uses
2
Duration
15.5s
Transcript
/tmp/claude-1000/synthetic/tasks/cccc333.output
diff --git a/test/test_data/monitor_tool.jsonl b/test/test_data/monitor_tool.jsonl new file mode 100644 index 00000000..b07483d3 --- /dev/null +++ b/test/test_data/monitor_tool.jsonl @@ -0,0 +1,4 @@ +{"type":"user","uuid":"u-root","parentUuid":null,"timestamp":"2026-05-08T10:00:00.000Z","sessionId":"s1","version":"2.1.128","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":"watch the PR"}} +{"type":"assistant","uuid":"u-monitor-call","parentUuid":"u-root","timestamp":"2026-05-08T10:00:01.000Z","sessionId":"s1","version":"2.1.128","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-1","model":"claude-opus-4-7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_monitor_42","name":"Monitor","input":{"description":"PR #140 check transitions","command":"prev=\"\"\nwhile true; do\n cur=$(gh pr checks 140 || true)\n diff <(echo \"$prev\") <(echo \"$cur\")\n prev=\"$cur\"\n sleep 30\ndone","timeout_ms":3600000,"persistent":false}}]}} +{"type":"user","uuid":"u-monitor-result","parentUuid":"u-monitor-call","timestamp":"2026-05-08T10:00:02.000Z","sessionId":"s1","version":"2.1.128","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_monitor_42","content":"Monitor started (task b07h5t4ng, timeout 3600000ms). You will be notified on each event. Keep working — do not poll or sleep. Events may arrive while you are waiting for the user — an event is not their reply."}]}} +{"type":"user","uuid":"u-task-notification","parentUuid":"u-monitor-result","timestamp":"2026-05-08T10:30:00.000Z","sessionId":"s1","version":"2.1.128","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":"\nb07h5t4ng\ntoolu_test_monitor_42\n/tmp/tasks/b07h5t4ng.output\ncompleted\nMonitor \"PR #140 check transitions\" stream ended\n"},"origin":{"kind":"task-notification"}} diff --git a/test/test_monitor_rendering.py b/test/test_monitor_rendering.py new file mode 100644 index 00000000..141144c3 --- /dev/null +++ b/test/test_monitor_rendering.py @@ -0,0 +1,366 @@ +"""Test cases for the built-in ``Monitor`` tool rendering (#142). + +Covers four concerns: + +1. Input/output factory — typed ``MonitorInput`` / ``MonitorOutput`` + are produced from raw tool_use / tool_result entries. +2. HTML rendering — title carries the description; body grid carries + description / command (collapsible) / timeout_ms / persistent; + result paragraph renders verbatim. +3. Markdown rendering — title + bullet list + fenced command; + result text passes through. +4. Task-end backlink — when a ```` carries + ```` matching the originating Monitor call's id, the + rendered Task ID value is wrapped in an anchor pointing at the + Monitor card's message div. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import pytest + +from claude_code_log.converter import load_transcript +from claude_code_log.factories.tool_factory import ( + parse_monitor_output, +) +from claude_code_log.html.renderer import HtmlRenderer +from claude_code_log.html.tool_formatters import ( + format_monitor_input, + format_monitor_output, +) +from claude_code_log.markdown.renderer import MarkdownRenderer +from claude_code_log.models import ( + MonitorInput, + MonitorOutput, + ToolResultContent, +) + + +FIXTURE = Path(__file__).parent / "test_data" / "monitor_tool.jsonl" + + +# ----------------------------------------------------------------------------- +# Direct factory tests +# ----------------------------------------------------------------------------- + + +class TestMonitorInputModel: + def test_required_fields(self) -> None: + m = MonitorInput( + description="watch logs", + command="tail -f /var/log/app.log", + timeout_ms=60000, + persistent=True, + ) + assert m.description == "watch logs" + assert m.command == "tail -f /var/log/app.log" + assert m.timeout_ms == 60000 + assert m.persistent is True + + def test_optional_fields_default_none(self) -> None: + m = MonitorInput(description="d", command="c") + assert m.timeout_ms is None + assert m.persistent is None + + +class TestMonitorOutputParser: + def test_parses_task_id_from_start_message(self) -> None: + result = ToolResultContent( + type="tool_result", + tool_use_id="x", + content=( + "Monitor started (task b07h5t4ng, timeout 3600000ms). " + "You will be notified on each event." + ), + ) + out = parse_monitor_output(result, file_path=None) + assert isinstance(out, MonitorOutput) + assert out.task_id == "b07h5t4ng" + assert "Monitor started" in out.text + + def test_falls_back_to_text_when_format_unknown(self) -> None: + result = ToolResultContent( + type="tool_result", + tool_use_id="x", + content="Something else entirely.", + ) + out = parse_monitor_output(result, file_path=None) + assert out is not None + assert out.task_id is None + assert out.text == "Something else entirely." + + def test_empty_returns_none(self) -> None: + result = ToolResultContent(type="tool_result", tool_use_id="x", content="") + assert parse_monitor_output(result, file_path=None) is None + + +# ----------------------------------------------------------------------------- +# HTML formatter tests (direct) +# ----------------------------------------------------------------------------- + + +class TestMonitorHtmlFormatter: + def test_input_grid_includes_all_fields(self) -> None: + m = MonitorInput( + description="watch", command="echo hi", timeout_ms=500, persistent=False + ) + html = format_monitor_input(m) + # Four labelled rows. + assert "description" in html + assert "command" in html + assert "timeout_ms" in html + assert "persistent" in html + # Values present. + assert "watch" in html + assert "echo hi" in html + assert "500" in html + assert "False" in html + + def test_short_command_uses_pre_inline(self) -> None: + """Short single-line command renders inside ``
``,
+        no collapsible-code wrapper — chrome wouldn't pay for itself."""
+        m = MonitorInput(description="d", command="echo hi")
+        html = format_monitor_input(m)
+        assert "
" in html
+        assert "collapsible-code" not in html
+
+    def test_long_command_uses_collapsible_block(self) -> None:
+        """Multi-line / long command uses ``render_collapsible_code`` —
+        mirrors how other tool inputs render long bodies, with a line
+        count badge so the reader knows the size before expanding."""
+        long_command = "\n".join(f"line {i}" for i in range(20))
+        m = MonitorInput(description="d", command=long_command)
+        html = format_monitor_input(m)
+        assert "collapsible-code" in html
+        assert "20 lines" in html
+
+    def test_optional_fields_omitted_when_none(self) -> None:
+        m = MonitorInput(description="d", command="c")
+        html = format_monitor_input(m)
+        # No timeout_ms or persistent rows when both are None.
+        assert "timeout_ms" not in html
+        assert "persistent" not in html
+
+    def test_output_paragraph_verbatim(self) -> None:
+        out = MonitorOutput(text="Monitor started (task abc).")
+        html = format_monitor_output(out)
+        assert "
" in html + assert "Monitor started (task abc)." in html + + +# ----------------------------------------------------------------------------- +# End-to-end fixture tests +# ----------------------------------------------------------------------------- + + +class TestMonitorFixtureRendering: + """Drive the real HTML/Markdown renderers against ``test_data/monitor_tool.jsonl``. + + Covers the full pipeline: tool_use → MonitorInput, tool_result → + MonitorOutput, and the ```` → backlink wiring. + """ + + @staticmethod + def _html() -> str: + msgs = load_transcript(FIXTURE) + return HtmlRenderer().generate(msgs, "Test") + + @staticmethod + def _md() -> str: + msgs = load_transcript(FIXTURE) + return MarkdownRenderer().generate(msgs, "Test") + + def test_html_title_contains_monitor_icon_and_description(self) -> None: + html = self._html() + # Title includes the telescope icon and the description. + assert "🔭" in html + assert "PR #140 check transitions" in html + + def test_html_grid_has_four_rows(self) -> None: + html = self._html() + # Each labelled row appears once for the Monitor card. + for label in ("description", "command", "timeout_ms", "persistent"): + assert label in html + assert "3600000" in html # timeout_ms value + assert "False" in html # persistent value + + def test_html_command_is_collapsible(self) -> None: + """The fixture's command is multi-line bash — should land in + the collapsible-code wrapper with a line-count badge. + + The fixture command has 7 lines (six explicit newlines + one + terminal line); ``line_count = command.count('\\n') + 1``. + """ + html = self._html() + assert "collapsible-code" in html + assert "7 lines" in html + + def test_html_result_paragraph_present(self) -> None: + html = self._html() + assert "Monitor started (task b07h5t4ng" in html + assert "monitor-output" in html + + def test_html_task_notification_task_id_links_to_monitor(self) -> None: + """The Task ID value in the notification card is wrapped in an + anchor pointing at the Monitor tool_use card's message div.""" + html = self._html() + + # Locate the Monitor tool_use's message div id (msg-d-N). + monitor_div_match = re.search( + r"
]*id='(msg-d-\d+)'", + html, + ) + assert monitor_div_match, "Monitor tool_use div not found" + monitor_anchor_id = monitor_div_match.group(1) + + # Find the Task ID row's anchor — should reference the Monitor's + # anchor id. + task_id_link = re.search( + r"" + r"b07h5t4ng", + html, + ) + assert task_id_link, "Task ID link to Monitor not found" + assert task_id_link.group(1) == monitor_anchor_id, ( + f"Task ID link points at {task_id_link.group(1)}, " + f"expected {monitor_anchor_id}" + ) + + def test_markdown_title_and_command_fence(self) -> None: + md = self._md() + # Title with telescope and description. + assert "🔭 Monitor PR #140 check transitions" in md + # Bullet list with all four fields. + assert "- **description:** PR #140 check transitions" in md + assert "- **timeout_ms:** 3600000" in md + assert "- **persistent:** False" in md + # Command in a fenced bash block. + assert "```bash" in md + assert "deadline" in md or "while true" in md + + def test_markdown_output_text_present(self) -> None: + md = self._md() + assert "Monitor started (task b07h5t4ng" in md + + +# ----------------------------------------------------------------------------- +# Standalone backlink tests — TaskNotificationMessage in isolation +# ----------------------------------------------------------------------------- + + +class TestTaskNotificationToolUseIdBacklink: + """The backlink wiring is independent of the Monitor flow. + + Any TaskNotificationMessage carrying ``tool_use_id`` should have + its Task ID rendered as an anchor when the matching tool_use is + present in the same transcript. These tests pin the contract via + the same fixture so a future refactor of either side stays linked. + """ + + def test_task_notification_carries_tool_use_id_after_parse(self) -> None: + """Sanity: the factory now extracts ````.""" + from claude_code_log.factories.task_notification_factory import ( + create_task_notification_message, + ) + from claude_code_log.models import MessageMeta + + meta = MessageMeta(session_id="s", timestamp="2026-05-08T10:00:00Z", uuid="u") + text = ( + "\n" + "abc123\n" + "toolu_xyz\n" + "completed\n" + "Monitor stream ended\n" + "" + ) + notification = create_task_notification_message(meta, text) + assert notification is not None + assert notification.task_id == "abc123" + assert notification.tool_use_id == "toolu_xyz" + + def test_legacy_notification_without_tool_use_id_still_parses(self) -> None: + """Notifications predating the ```` field must + keep working — ``tool_use_id`` is just None in that case.""" + from claude_code_log.factories.task_notification_factory import ( + create_task_notification_message, + ) + from claude_code_log.models import MessageMeta + + meta = MessageMeta(session_id="s", timestamp="2026-05-08T10:00:00Z", uuid="u") + text = ( + "\n" + "abc123\n" + "completed\n" + "Old notification\n" + "" + ) + notification = create_task_notification_message(meta, text) + assert notification is not None + assert notification.task_id == "abc123" + assert notification.tool_use_id is None + + def test_no_backlink_when_tool_use_id_does_not_match(self, tmp_path) -> None: + """When the notification's tool_use_id doesn't match any + tool_use in the transcript, the Task ID renders as plain + ```` — no orphan anchor pointing at nothing.""" + # Build a fixture where the notification references a tool_use_id + # that doesn't exist. + import json + + ts = "2026-05-08T10:00:00.000Z" + lines: list[dict[str, object]] = [ + { + "type": "user", + "uuid": "u1", + "parentUuid": None, + "timestamp": ts, + "sessionId": "s1", + "version": "2.1.128", + "cwd": "/tmp", + "userType": "external", + "isSidechain": False, + "message": {"role": "user", "content": "hi"}, + }, + { + "type": "user", + "uuid": "u2", + "parentUuid": "u1", + "timestamp": ts, + "sessionId": "s1", + "version": "2.1.128", + "cwd": "/tmp", + "userType": "external", + "isSidechain": False, + "message": { + "role": "user", + "content": ( + "\n" + "orphan_task\n" + "toolu_does_not_exist\n" + "completed\n" + "orphan\n" + "" + ), + }, + }, + ] + fn = tmp_path / "orphan.jsonl" + fn.write_text("\n".join(json.dumps(line) for line in lines)) + msgs = load_transcript(fn) + html = HtmlRenderer().generate(msgs, "Test") + # Task ID row is present but NOT wrapped in a backlink anchor. + # Check for the rendered usage (`` None: + """The fixture file must exist for the end-to-end tests to run.""" + if not FIXTURE.exists(): + pytest.skip(f"Fixture missing: {FIXTURE}") From 9bfd1b5b46a0892fb0573126c9a4530ed09b2012 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 9 May 2026 00:15:35 +0200 Subject: [PATCH 2/2] Address CodeRabbit review on PR #147 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all applied. None blocking; all small. ## 1. Markdown title — escape via inline-code wrap `title_MonitorInput` in `markdown/renderer.py` interpolated `input.description` raw into the title heading. A description containing markdown emphasis / heading metacharacters (``*``, ``_``, ``` ` ```, ``[``, ...) would leak into the rendered title and either italicise unwanted runs or open code spans that swallow following text. Fix: wrap the description in inline code via the existing `_inline_code` helper, matching the recipe already used by `title_WebSearchInput` / `title_WebFetchInput` / `title_SkillInput`. The helper widens the fence past any backtick run in the value and pads when the value starts/ends with a tick — same protection, established convention. Title becomes ``🔭 Monitor `` ``. The HTML side was already safe (`_tool_title` routes through `escape_html`); the asymmetry is because HTML and Markdown have different metacharacter sets. ## 2. Test fixture scope — class-only via `@pytest.mark.usefixtures` `_ensure_fixture_present` was `scope="module", autouse=True`, which means the *entire* test module skips when `monitor_tool.jsonl` is missing — including the model-parsing / formatter-unit / backlink-contract tests that don't need the fixture at all. Fix: drop `autouse`, set `scope="class"`, and apply via `@pytest.mark.usefixtures("_ensure_fixture_present")` on `TestMonitorFixtureRendering` only. Now only the fixture-driven end-to-end tests skip when the JSONL is absent; the rest of the suite still runs. (Pyright is added to the ignore list for `reportUnusedFunction` on the fixture itself — pytest accesses it by name string via the mark, which the static checker can't see. Standard pytest fixture pattern.) ## 3. Dead `params` dict removed (nitpick) `format_monitor_input` populated a `params` dict but never used it — leftover from an earlier draft that routed through `render_params_table`. The final shape composes rows by hand to keep the specialised collapsible-command cell. Dropped the dict, the two later assignments, and the stale comment that mentioned the discarded approach. ## Tests - `just test`: 1626 pass, 7 skipped (unchanged count — no test added, just adjusted assertions and fixture wiring). - `test_markdown_title_and_command_fence` updated to expect the new inline-code-wrapped title. - ruff + pyright clean on the touched files. --- claude_code_log/html/tool_formatters.py | 11 ----------- claude_code_log/markdown/renderer.py | 12 ++++++++++-- test/test_monitor_rendering.py | 22 ++++++++++++++++------ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index f4c83f7b..058e2c9e 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -699,12 +699,6 @@ def format_monitor_input(monitor_input: MonitorInput) -> str: body row anchors the rendering to the harness's exact field name and keeps the card useful when a future title format changes. """ - params: dict[str, Any] = {"description": monitor_input.description} - # Command rendered separately for adaptive collapsibility — pass - # a placeholder through the table builder, then replace with the - # specialised cell. This keeps the row layout consistent with - # ``render_params_table`` (same CSS hooks downstream) while letting - # us keep the line-count badge for the multi-line case. command = monitor_input.command line_count = command.count("\n") + 1 escaped_command = escape_html(command) @@ -718,11 +712,6 @@ def format_monitor_input(monitor_input: MonitorInput) -> str: else: command_cell = f"
{escaped_command}
" - if monitor_input.timeout_ms is not None: - params["timeout_ms"] = monitor_input.timeout_ms - if monitor_input.persistent is not None: - params["persistent"] = monitor_input.persistent - # Compose the table by hand so the ``command`` row carries the # specialised cell instead of a generic stringification. rows: list[str] = [] diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index f9f160af..a42c57ab 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -1419,8 +1419,16 @@ def title_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str: return f"🌐 WebFetch `{url}`" def title_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: - """Title → '🔭 Monitor '.""" - return f"🔭 Monitor {input.description}" + """Title → '🔭 Monitor ``'. + + Wraps the description in inline code via ``_inline_code`` — + same recipe as ``title_WebSearchInput`` / ``title_WebFetchInput`` + / ``title_SkillInput``. The helper widens the fence past any + backtick run in the value and escapes the value from + Markdown emphasis / heading metacharacters that would otherwise + leak into the rendered title (e.g. ``*`` / ``_`` / ``[``). + """ + return f"🔭 Monitor {_inline_code(input.description)}" def title_SkillInput(self, input: SkillInput, _: TemplateMessage) -> str: """Title → '💡 Skill ``'.""" diff --git a/test/test_monitor_rendering.py b/test/test_monitor_rendering.py index 141144c3..32605f27 100644 --- a/test/test_monitor_rendering.py +++ b/test/test_monitor_rendering.py @@ -156,6 +156,7 @@ def test_output_paragraph_verbatim(self) -> None: # ----------------------------------------------------------------------------- +@pytest.mark.usefixtures("_ensure_fixture_present") class TestMonitorFixtureRendering: """Drive the real HTML/Markdown renderers against ``test_data/monitor_tool.jsonl``. @@ -231,8 +232,11 @@ def test_html_task_notification_task_id_links_to_monitor(self) -> None: def test_markdown_title_and_command_fence(self) -> None: md = self._md() - # Title with telescope and description. - assert "🔭 Monitor PR #140 check transitions" in md + # Title wraps the description in inline code (matches + # WebSearch / WebFetch / Skill convention; protects against + # markdown emphasis / heading metacharacters in user-supplied + # descriptions). + assert "🔭 Monitor `PR #140 check transitions`" in md # Bullet list with all four fields. assert "- **description:** PR #140 check transitions" in md assert "- **timeout_ms:** 3600000" in md @@ -302,7 +306,7 @@ def test_legacy_notification_without_tool_use_id_still_parses(self) -> None: assert notification.task_id == "abc123" assert notification.tool_use_id is None - def test_no_backlink_when_tool_use_id_does_not_match(self, tmp_path) -> None: + def test_no_backlink_when_tool_use_id_does_not_match(self, tmp_path: Path) -> None: """When the notification's tool_use_id doesn't match any tool_use in the transcript, the Task ID renders as plain ```` — no orphan anchor pointing at nothing.""" @@ -359,8 +363,14 @@ def test_no_backlink_when_tool_use_id_does_not_match(self, tmp_path) -> None: assert "
None: - """The fixture file must exist for the end-to-end tests to run.""" +@pytest.fixture(scope="class") +def _ensure_fixture_present() -> None: # pyright: ignore[reportUnusedFunction] + """Skip the end-to-end fixture-driven tests when the JSONL fixture + is missing. Class-scoped + opt-in via ``@pytest.mark.usefixtures`` + so unrelated tests in the module (model parsing, formatter unit + checks, the orthogonal backlink-contract tests) still run when the + fixture file is absent — only ``TestMonitorFixtureRendering`` + actually depends on it. + """ if not FIXTURE.exists(): pytest.skip(f"Fixture missing: {FIXTURE}")