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..058e2c9e 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,70 @@ 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. + """ + 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}
" + + # 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 +938,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 +949,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..a42c57ab 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,18 @@ 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 ``'. + + 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 ``'.""" 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..32605f27 --- /dev/null +++ b/test/test_monitor_rendering.py @@ -0,0 +1,376 @@ +"""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 +# ----------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("_ensure_fixture_present") +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 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 + 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: 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: # 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}")