Skip to content

Support async agents (#90)#132

Merged
cboos merged 19 commits intomainfrom
dev/async-agents
Apr 27, 2026
Merged

Support async agents (#90)#132
cboos merged 19 commits intomainfrom
dev/async-agents

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Apr 26, 2026

Summary

Adds rendering support for Claude Code's async-agent flow (issue #90):
Task tool_use with run_in_background=True, TaskOutput polling
tool, and <task-notification> user entries injected when the agent
completes. The agent's answer surfaces at the spawn site (folded
under the spawning Task tool_result) so the reader sees it where they
expect, not buried at the tail of the relocated sidechain.

Phases

  1. Typed models + parsers (d575c75) — TaskOutputInput (Pydantic),
    TaskOutputResult, TaskNotificationMessage, TaskNotificationUsage
    (dataclasses); task_notification_factory parses
    <task-notification> user content; parse_taskoutput_output parses
    the <retrieval_status>/<task_id>/<task_type>/<status>/<output>
    poll body.
  2. HTML + Markdown rendering (8b21baf) — 🔍 TaskOutput #<id>
    poll cards, 🔄 Async result · *summary* notification cards, and
    the [async] hint badge on the Task tool_use title when
    run_in_background=True.
  3. Spawn-fold + notification dedup:
    • 483bd09 — flag notifications whose result text duplicates the
      last sub-assistant; render them as backlink stubs.
    • 3396601 — place task_notification at hierarchy level 3 so
      surrounding messages don't accidentally nest under it.
    • 7f6ff89 — fold the agent's final answer onto
      TaskOutput.async_final_answer and drop the duplicate
      sub-assistant from the sidechain tree.
    • 4fd194e — stop the .tool_result .collapsible-code negative
      margin from yanking the async-answer fold over its label.
  4. Tests + fixture (7c60c86) — synthetic test/test_data/async_agents/
    fixture sliced from the canonical clmail-monk transcript;
    test/test_async_agents.py (32 tests) covers parser positive/edge
    cases, fixture loading, factory dispatch, the rendering pipeline,
    and detail-level invariants. HTML + Markdown snapshots locked in.
  5. Detail-level handling:
    • aeb2e1b (Plan A) — source the spawn-fold from the notification's
      result_text instead of from the (now-stripped) sidechain
      assistant, so the fold survives at --detail low.
    • 1f072de — drop duplicate-flagged notifications at LOW
      (post-link pass; the spawn-fold already shows the answer, the
      standalone card is pure redundancy at this terse level).
    • e1ecbd5 — register TaskNotificationMessage in
      CSS_CLASS_REGISTRY (["user", "task_notification"]) so the
      runtime "User" filter toggle keeps the card visible (without
      it the only class was task_notification, matching no toolbar
      type and permanently flagged filtered-hidden).
    • 955a1e9 — re-run _identify_message_pairs after the LOW drop
      pass so the Task tool_use ↔ tool_result CSS pairing classes
      and the Markdown's paired-body rendering both stay correct.
  6. Lint cleanup (6c4e953) — drop four unused
    # type: ignore[no-untyped-def] test-helper comments flagged by
    uv run ty check.

Detail-level matrix (canonical clmail-monk fixture, 4 async tasks)

level spawn-folds notification cards answer visible
full 4 4 (full metadata) yes (folded)
high 4 4 (full metadata) yes (folded)
low 4 0 (dropped) yes (folded)
minimal 0 4 (with body) yes (notification body)
user-only 0 4 (with body) yes (notification body)

Answer is visible exactly once at every detail level.

Test plan

  • uv run pytest -n auto -m "not (tui or browser)" — 1123 passed, 7 skipped
  • uv run pyright — 0 errors, 0 warnings
  • uv run ty check — all checks passed
  • uv run ruff check — all checks passed
  • HTML + Markdown snapshot suites refreshed
  • Manual verification on canonical clmail-monk fixture at every detail level

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Async background tasks: polling, TaskOutput parsing, task-notification cards (id/status/summary/result/usage/transcript), collapsible bodies, and backlinking with folded async final answers into spawning task results
  • Bug Fixes

    • Duplicate-result detection/suppression to avoid double-rendering and remove redundant sub-assistant nodes
  • Documentation

    • Added docs for async-agent flow, message types, folding, and detail-level visibility
  • Tests

    • New integration and snapshot tests covering parsing, folding, rendering, and detail-level behavior
  • Style

    • UI styles and status indicators for async task cards

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds async-agent support: parse <task-notification> user entries and TaskOutput polling results, introduce models and factories, register tool dispatch, stitch and fold notifications into spawning Task tool results during rendering, add HTML/Markdown formatters and CSS, and include end-to-end tests and fixtures.

Changes

Cohort / File(s) Summary
Models
claude_code_log/models.py
Adds TaskNotificationUsage, TaskNotificationMessage, TaskOutputInput, TaskOutputResult; adds TaskOutput.async_final_answer.
User & Parsing Factories
claude_code_log/factories/task_notification_factory.py, claude_code_log/factories/user_factory.py
New task-notification factory with has_task_notification and create_task_notification_message; user factory dispatches parsed TaskNotificationMessage early and updates UserMessageContent union.
Tool Factory
claude_code_log/factories/tool_factory.py
Registers TaskOutput input parsing and parse_taskoutput_output returning TaskOutputResult (handles truncation/output file detection); updates output parser registry.
Renderer Core
claude_code_log/renderer.py
Adds _link_async_notifications to link notifications to spawning Task/tool_result (via agentId or fallback), fold result_text into TaskOutput.async_final_answer, mark duplicates, remove duplicate sidechain assistant nodes, and set task_notification hierarchy level.
HTML Formatting & CSS
claude_code_log/html/async_formatter.py, claude_code_log/html/renderer.py, claude_code_log/html/tool_formatters.py, claude_code_log/html/utils.py, claude_code_log/html/templates/components/teammate_styles.css, claude_code_log/html/templates/components/timeline.html
Adds async formatter module and renderer hooks for TaskOutput/TaskNotificationMessage; updates tool formatter to render folded async answers; maps CSS classes and timeline grouping; adds backlink and async hint styling.
Markdown Rendering
claude_code_log/markdown/renderer.py
Adds markdown formatters/titles for TaskOutputInput, TaskOutputResult, and TaskNotificationMessage; renders stub + folded async final answer and applies LOW-detail suppression for duplicate notifications.
Tests & Fixtures
test/test_async_agents.py, test/test_snapshot_html.py, test/test_snapshot_markdown.py, test/__snapshots__/test_snapshot_markdown.ambr, test/test_data/async_agents/...
Adds unit/integration tests for detection/parsing/dispatch, end-to-end linking/folding/dedup behavior, HTML/Markdown snapshots, and JSONL fixtures including subagent sidechain.
Docs & Worknotes
work/async-agents.md, dev-docs/agents.md, dev-docs/messages.md, dev-docs/teammates.md, dev-docs/FOLD_STATE_DIAGRAM.md, test/test_data/async_agents/README.md
Adds design and usage docs describing formats, stitching/folding rules, hierarchy mapping, detail-level behaviors, and fixture expectations.
Templates
claude_code_log/html/templates/components/timeline.html
Adds task-notification timeline group and ensures proper grouping/order in timeline rendering.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant UserFactory as UserFactory
    participant TaskNotificationFactory as TaskNotificationFactory
    participant Renderer as Renderer
    participant ToolFactory as ToolFactory
    participant HtmlFormatter as HtmlFormatter

    User->>UserFactory: create_user_message(text)
    UserFactory->>TaskNotificationFactory: has_task_notification(text)
    alt notification found
        UserFactory->>TaskNotificationFactory: create_task_notification_message(meta, text)
        TaskNotificationFactory-->>UserFactory: TaskNotificationMessage
        UserFactory-->>Renderer: add TaskNotificationMessage
    else regular user text
        UserFactory-->>Renderer: add TextUserMessage
    end

    Renderer->>ToolFactory: parse tool results (Task / TaskOutput)
    ToolFactory-->>Renderer: TaskOutputInput / TaskOutputResult
    Renderer->>Renderer: _link_async_notifications()
    Note right of Renderer: extract agentId (metadata or regex)\nlink notification -> spawning tool_result\nfold result_text -> TaskOutput.async_final_answer\nmark result_is_duplicate / prune duplicate sidechain

    Renderer->>HtmlFormatter: format_task_notification_content(notification)
    HtmlFormatter-->>Renderer: html_string
    Renderer-->>User: final rendered transcript
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • daaain

Poem

🐰 Hops to parse each async note with cheer,

Task IDs and summaries now appear.
Folded answers stitched where spawns began,
Duplicates tucked neatly from the clan.
A rabbit hums — async tasks are here!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Support async agents (#90)' directly reflects the main objective of the changeset, which comprehensively adds parsing, rendering, and session-level stitching for async-agent workflows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/async-agents

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
claude_code_log/html/templates/components/teammate_styles.css (1)

273-277: Both .task-update-card and .task-notification-card use --cc-blue.

Just a note: the new .task-notification-card shares the cyan/blue palette with .task-update-card (line 324–328). They likely don't co-appear in most transcripts, so the visual scan from cyan (task-output-card) → blue (task-notification-card) still works — but if you ever want to disambiguate at a glance, picking a distinct token (e.g. --cc-purple or --cc-pink) for the notification card would make the spawn → poll → notification chain three distinct colors instead of two.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/templates/components/teammate_styles.css` around lines
273 - 277, The .teammate-tool-card.task-notification-card currently reuses
--cc-blue (same as .task-update-card); change it to a distinct token (e.g.
--cc-purple or --cc-pink) to improve visual disambiguation: update the CSS block
for .teammate-tool-card.task-notification-card to set --cc-color:
var(--cc-purple) (or var(--cc-pink)) and keep border-left: 4px solid
var(--cc-color); if --cc-purple/--cc-pink don't exist yet, add the variable to
the global/root color definitions so the new token resolves.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/html/utils.py`:
- Around line 71-76: Add support for the "task_notification" message type in
timeline rendering: add a new entry for "task_notification" in messageTypeGroups
to define its display styling, and update the message-type detection logic to
explicitly check for "task_notification" (using the same pattern as the existing
teammate check) before the generic .find() that classifies messages — this
ensures messages whose CSS_CLASS_REGISTRY entry TaskNotificationMessage =
["user","task_notification"] are detected as task_notification rather than
falling back to the user type.
- Line 76: The registry entry TaskNotificationMessage currently uses an
underscore for the CSS modifier; update the modifier string in the registry from
"task_notification" to "task-notification" (i.e., change
TaskNotificationMessage: ["user", "task_notification"] to use
"task-notification") so it matches the CSS rules in teammate_styles.css and the
rest of the modifier naming convention; do not change the message type
identifiers in models.py or renderer.py (they remain "task_notification").

In `@claude_code_log/renderer.py`:
- Around line 2163-2166: The code currently sets
notification.result_is_duplicate whenever spawn_target_kept is true, which can
hide the final answer when content.output wasn't parsed as a TaskOutput; change
the logic so that you only set notification.result_is_duplicate = True when
spawn_target_kept is true AND isinstance(content.output, TaskOutput) (and still
assign content.output.async_final_answer = notification.result_text in that same
branch); reference content.output, TaskOutput, notification.result_is_duplicate,
spawn_target_kept and _async_agent_id_from_tool_result() to locate the relevant
conditional and update it accordingly.
- Around line 835-837: The LOW-detail duplicate-drop step mutates
message_index/parent_message_index after session_nav was built in
generate_template_messages, causing misaligned indices; either call
_drop_duplicate_notifications_at_low(ctx) before prepare_session_navigation()
inside generate_template_messages, or after the drop explicitly rebuild
session_nav (the same structure prepare_session_navigation produces) so
session_nav reflects the updated message_index/parent_message_index; update the
codepath that handles DetailLevel.LOW (the if detail == DetailLevel.LOW block)
to perform one of these fixes and ensure downstream uses of session_nav use the
rebuilt version.

---

Nitpick comments:
In `@claude_code_log/html/templates/components/teammate_styles.css`:
- Around line 273-277: The .teammate-tool-card.task-notification-card currently
reuses --cc-blue (same as .task-update-card); change it to a distinct token
(e.g. --cc-purple or --cc-pink) to improve visual disambiguation: update the CSS
block for .teammate-tool-card.task-notification-card to set --cc-color:
var(--cc-purple) (or var(--cc-pink)) and keep border-left: 4px solid
var(--cc-color); if --cc-purple/--cc-pink don't exist yet, add the variable to
the global/root color definitions so the new token resolves.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fac7496e-5c54-4d32-9495-017177c236ed

📥 Commits

Reviewing files that changed from the base of the PR and between 9b9d3c5 and 6c4e953.

📒 Files selected for processing (21)
  • claude_code_log/factories/task_notification_factory.py
  • claude_code_log/factories/tool_factory.py
  • claude_code_log/factories/user_factory.py
  • claude_code_log/html/async_formatter.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/html/tool_formatters.py
  • claude_code_log/html/utils.py
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/FOLD_STATE_DIAGRAM.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_async_agents.py
  • test/test_data/async_agents/README.md
  • test/test_data/async_agents/eb000000-0000-4000-8000-000000000001.jsonl
  • test/test_data/async_agents/eb000000-0000-4000-8000-000000000001/subagents/agent-cccc333.jsonl
  • test/test_snapshot_html.py
  • test/test_snapshot_markdown.py
  • work/async-agents.md

Comment thread claude_code_log/html/utils.py Outdated
Comment thread claude_code_log/html/utils.py Outdated
Comment thread claude_code_log/renderer.py Outdated
Comment thread claude_code_log/renderer.py Outdated
@cboos cboos mentioned this pull request Apr 27, 2026
cboos added a commit that referenced this pull request Apr 27, 2026
… naming

- New `dev-docs/agents.md` covers all three flavors of Task-spawned
  agents (sync sub-agents #79, async task agents #90, teammates #91).
  The async-agents § is the new detail: pipeline shape diagram,
  the two `TaskOutput`-named dataclasses (`TaskOutput` on the Task
  tool_result vs `TaskOutputResult` on the polling tool), the
  Phase 3 fold mechanics, the per-detail-level visibility matrix,
  key files, and the test fixture pointer.

- `dev-docs/messages.md` gains:
  - A `TaskOutput` polling-tool row in the Tool Results table and
    the Available Tools matrix (was previously absent).
  - A note on the `TaskOutput` vs `TaskOutputResult` name collision,
    forwarding to agents.md § 2.2.
  - A new "Async Task Notification" subsection under user content
    documenting `TaskNotificationMessage` (Phase 3 dedup markers
    included), forwarding to agents.md § 2 for the end-to-end flow.

- `dev-docs/teammates.md` § 10.1 ("Standard sub-agents and async
  task agents") now reflects that #90 is shipped — points readers
  at agents.md § 2 for the as-built reference. The doc's top-of-
  file companion-doc list and References block both gain agents.md.

Surfaced by user feedback on PR #132: the `TaskOutput` ↔
`TaskOutputResult` name collision is genuinely confusing; documenting
the distinction in the canonical message-type reference + a
focused agents.md should keep future readers (and Claude) on track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos added a commit that referenced this pull request Apr 27, 2026
…tput

CodeRabbit review on PR #132: `_link_async_notifications` set
`notification.result_is_duplicate = True` whenever a Task tool_result
matched a notification by `agent_id`, even when `content.output` was
not a parsed `TaskOutput`. `_async_agent_id_from_tool_result` has
three pathways — the third (regex fallback on raw text) supports
shapes the parser couldn't structure into a `TaskOutput`. On those
shapes we'd skip the actual fold (no `async_final_answer` field to
write into) but still suppress the notification body, silently losing
the agent's only visible answer.

Move the duplicate flag inside the `isinstance(content.output,
TaskOutput)` guard. The notification body now stays visible whenever
the fold can't land — preserving "answer visible at least once" at
every detail level.

Also rebuild `session_nav` after `_drop_duplicate_notifications_at_low`
runs at LOW. The drop pass remaps `ctx.session_first_message` indices,
but `session_nav` was built earlier with the pre-drop indices baked
into its `message_index` and `parent_message_index` fields. The
single-session canonical fixture didn't surface this (notifications
sit after the only session header, so dropping them doesn't shift the
header), but a multi-session transcript with notifications between
session headers would land nav anchors one (or more) message slot off
after the LOW reindex. Reuses the same `prepare_session_navigation`
call with the up-to-date ctx.

Verified: 1123 unit tests pass, pyright + ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos added a commit that referenced this pull request Apr 27, 2026
…meline

CodeRabbit review on PR #132 flagged two paired issues:

- Naming convention: every other modifier in `CSS_CLASS_REGISTRY`
  uses hyphens (`system-hook`, `slash-command`, `command-output`,
  `bash-input`, `bash-output`); the actual CSS rules in
  `teammate_styles.css` also use hyphens
  (`task-notification-card`, `task-notification-backlink`). The
  registry entry I added for `TaskNotificationMessage` was the
  underscored `"task_notification"` — no `.task_notification` CSS
  rule exists, making the underscore version dead.

- Timeline misclassification: `timeline.html` only knew the seven
  toolbar types plus `teammate`/`sidechain`/etc. With both `user`
  and `task-notification` classes on the rendered div, the
  generic `.find()` returned `user` first — async notifications
  landed in the User row of the timeline rather than getting their
  own group.

Rename the modifier to `task-notification` (Python message-type
identifier `"task_notification"` in `models.py` / `renderer.py`
unchanged — that's a separate code identifier), add a
`task-notification` entry to `messageTypeGroups` in `timeline.html`,
and add a `classList.includes('task-notification')` branch ahead of
the generic `.find()` (mirroring the existing `teammate` branch, for
the same reason: both carry the `user` class). The new group is
inserted in the timeline's `groupOrder` between `teammate` and
`system`.

Snapshot: HTML async-agents fixture now renders the div as
`message user task-notification …`; LOW snapshot still drops the
duplicate-flagged notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
dev-docs/messages.md (1)

365-366: Clarify the note reference for better readability.

Both rows reference *(see note)* but the actual note explaining the TaskOutput vs TaskOutputResult distinction appears 10 lines later (lines 375-382). This vague reference may cause readers to search for the note.

Consider making the reference more specific:

  • Change to *(see note below)*
  • Or add an anchor/reference: *(see § TaskOutput vs TaskOutputResult)*
  • Or place the note immediately after this table
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dev-docs/messages.md` around lines 365 - 366, Update the ambiguous note
reference for the table rows that show TaskOutput and TaskOutputResult: replace
`*(see note)*` with a clearer reference such as `*(see note below)*` or `*(see §
TaskOutput vs TaskOutputResult)*`, and either move the explanatory note for
TaskOutput/TaskOutputResult to immediately after the table or add a named
anchor/heading "TaskOutput vs TaskOutputResult" so readers can jump directly to
the explanation; ensure both table cells referencing TaskOutput and
TaskOutputResult use the same clarified phrasing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/renderer.py`:
- Around line 838-847: After LOW-detail reindexing in
_reindex_filtered_context() the TemplateMessage.ancestry and stored backlink
fields (SessionHeaderMessage.parent_message_index and
TaskNotificationMessage.spawning_task_message_index) are left with old indices;
update those to the new indices or rebuild the hierarchy to avoid broken d-{...}
anchors—either (1) remap TemplateMessage.ancestry and the two stored backlink
fields using the same old->new index map produced by _reindex_filtered_context()
before you call prepare_session_navigation(session_nav), or (2) call
_build_message_hierarchy()/_build_message_tree() again after the drop so
ancestry and parent/spawning indices are freshly computed; ensure this change is
applied where session_nav is rebuilt (the prepare_session_navigation call) and
also at the other indicated locations (lines flagged for the same issue).

In `@dev-docs/agents.md`:
- Around line 147-149: Update the docs to reference the correct registry and CSS
modifier: replace the incorrect `CSS_CLASS_REGISTRY` symbol with
`MESSAGE_TYPE_TO_CSS_CLASSES` and change the CSS modifier `task_notification` to
`task-notification` so the sentence reads that `MESSAGE_TYPE_TO_CSS_CLASSES`
maps `TaskNotificationMessage` to `["user", "task-notification"]`, ensuring
readers are pointed at the right symbol and class name.

---

Nitpick comments:
In `@dev-docs/messages.md`:
- Around line 365-366: Update the ambiguous note reference for the table rows
that show TaskOutput and TaskOutputResult: replace `*(see note)*` with a clearer
reference such as `*(see note below)*` or `*(see § TaskOutput vs
TaskOutputResult)*`, and either move the explanatory note for
TaskOutput/TaskOutputResult to immediately after the table or add a named
anchor/heading "TaskOutput vs TaskOutputResult" so readers can jump directly to
the explanation; ensure both table cells referencing TaskOutput and
TaskOutputResult use the same clarified phrasing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4c8aabd3-52b9-4541-925f-3877a1019ede

📥 Commits

Reviewing files that changed from the base of the PR and between 6c4e953 and 8501d5e.

📒 Files selected for processing (7)
  • claude_code_log/html/templates/components/timeline.html
  • claude_code_log/html/utils.py
  • claude_code_log/renderer.py
  • dev-docs/agents.md
  • dev-docs/messages.md
  • dev-docs/teammates.md
  • test/__snapshots__/test_snapshot_html.ambr
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/html/utils.py

Comment thread claude_code_log/renderer.py Outdated
Comment thread dev-docs/agents.md Outdated
Comment on lines +147 to +149
- `html/utils.py::CSS_CLASS_REGISTRY` —
`TaskNotificationMessage: ["user", "task_notification"]` so the
runtime "User" filter toggle keeps the card visible.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the referenced registry name and CSS modifier.

html/utils.py exposes MESSAGE_TYPE_TO_CSS_CLASSES, and the notification class is task-notification in the actual mapping. As written, this points readers at the wrong symbol and the wrong class name.

📝 Suggested doc fix
-- `html/utils.py::CSS_CLASS_REGISTRY` —
--   `TaskNotificationMessage: ["user", "task_notification"]` so the
+- `html/utils.py::MESSAGE_TYPE_TO_CSS_CLASSES` —
+-   `TaskNotificationMessage: ["user", "task-notification"]` so the
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `html/utils.py::CSS_CLASS_REGISTRY`
`TaskNotificationMessage: ["user", "task_notification"]` so the
runtime "User" filter toggle keeps the card visible.
- `html/utils.py::MESSAGE_TYPE_TO_CSS_CLASSES`
`TaskNotificationMessage: ["user", "task-notification"]` so the
runtime "User" filter toggle keeps the card visible.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dev-docs/agents.md` around lines 147 - 149, Update the docs to reference the
correct registry and CSS modifier: replace the incorrect `CSS_CLASS_REGISTRY`
symbol with `MESSAGE_TYPE_TO_CSS_CLASSES` and change the CSS modifier
`task_notification` to `task-notification` so the sentence reads that
`MESSAGE_TYPE_TO_CSS_CLASSES` maps `TaskNotificationMessage` to `["user",
"task-notification"]`, ensuring readers are pointed at the right symbol and
class name.

cboos added a commit that referenced this pull request Apr 27, 2026
CodeRabbit review on PR #132 found a third remap cascade — after
session_nav and pair refs, `TemplateMessage.ancestry` and the
`spawning_task_message_index` / `parent_message_index` backlink
fields are also frozen at hierarchy-build time, so any reindex
after the tree is built leaves them pointing at stale slots. The
"each fix unblocks the next bug" pattern was a sign the approach
was wrong.

Switch to "ghosting": the duplicate notification stays in
`ctx.messages` with its original `message_index`. Only its
*rendered output* disappears at LOW. Implementation: gate
`format_TaskNotificationMessage` and `title_TaskNotificationMessage`
in both `HtmlRenderer` and `MarkdownRenderer` on
`self.detail == LOW and content.result_is_duplicate` → return
`""`. The rendering loop's existing "skip empty messages" elision
(HTML: `if title or html or msg.children:`; Markdown:
`_render_message` returning `""` when there's no title and no
content) drops the entry from the visible output without touching
ancestry classes, backlinks, session nav, or pair refs.

This deletes:

- `_drop_duplicate_notifications_at_low` (the survivor list +
  `_reindex_filtered_context` + `_identify_message_pairs` re-run
  + tree-children prune).
- The post-link call site at the end of `generate_template_messages`.
- The `session_nav` rebuild that the reindex required.
- The `_identify_message_pairs` re-run that the pair-clear required.

Net: -88 lines in `renderer.py`, +42 across the two formatter
gates, no behavior change in the rendered HTML/Markdown at any
detail level.

Test refresh: `test_duplicate_notification_dropped_at_low` →
`test_duplicate_notification_ghosted_at_low`. Asserts the
notification is *still* in `ctx.messages` with
`result_is_duplicate=True`, and that both `HtmlRenderer` and
`MarkdownRenderer` return `""` for its title and body when
configured at LOW.

LOW snapshot regenerated: `message_id` indices for messages after
the (now ghosted) notification stay at their original values
instead of shifting down by one. Visible rendered output is
otherwise byte-equal.

Verified: 1123 unit tests pass, pyright + ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
claude_code_log/markdown/renderer.py (1)

891-894: Optional: make the Spawn backlink an actual link in Markdown.

The HTML version emits <a href="#msg-d-N">↱ Task</a>, but here the line is a static #d-N label that won't navigate in Markdown viewers. If anchors d-N (or whatever the Markdown side uses) are reachable, consider turning it into [↱ Task #d-N](#d-N) so readers can jump back to the spawn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/markdown/renderer.py` around lines 891 - 894, Replace the
static label written by lines.append when content.spawning_task_message_index is
set with a Markdown link that points to the same anchor used by the HTML output;
i.e., build the string from content.spawning_task_message_index and emit
something like "[↱ Task `#d-`{index}](`#msg-d-`{index})" instead of the current "-
**Spawn:** ↱ Task `#d-{index}`" so Markdown viewers can navigate back to the
spawn; update the lines.append call that references
content.spawning_task_message_index accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/html/renderer.py`:
- Around line 779-801: The docstring for title_TaskNotificationMessage promises
a "•" separator but the rendered title lacks it; update the function
title_TaskNotificationMessage to include the bullet (e.g., "🔄 Async result • ")
before the summary or task_id when building the return strings to match the
docstring and the markdown renderer (or alternatively update the docstring to
remove the bullet); locate and modify the branches that return the summary (the
string built with "<span class='tool-summary'>..."), the task_id branch (the
f"🔄 Async result <code>..."), and the default return to ensure consistent use
of the "•" separator.

---

Nitpick comments:
In `@claude_code_log/markdown/renderer.py`:
- Around line 891-894: Replace the static label written by lines.append when
content.spawning_task_message_index is set with a Markdown link that points to
the same anchor used by the HTML output; i.e., build the string from
content.spawning_task_message_index and emit something like "[↱ Task
`#d-`{index}](`#msg-d-`{index})" instead of the current "- **Spawn:** ↱ Task
`#d-{index}`" so Markdown viewers can navigate back to the spawn; update the
lines.append call that references content.spawning_task_message_index
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc4e598b-f34c-459a-8752-0593000c6d6b

📥 Commits

Reviewing files that changed from the base of the PR and between 8501d5e and ee263ed.

📒 Files selected for processing (6)
  • claude_code_log/html/renderer.py
  • claude_code_log/markdown/renderer.py
  • claude_code_log/renderer.py
  • dev-docs/agents.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/test_async_agents.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/test_async_agents.py
  • claude_code_log/renderer.py

Comment on lines +779 to +801
def title_TaskNotificationMessage(
self, content: TaskNotificationMessage, _: TemplateMessage
) -> str:
"""Title → '🔄 Async result • <summary>' for an async-agent
completion notification (issue #90). The summary is the most
useful at-a-glance hint; the rest of the metadata renders in
the body card.

Empty at ``DetailLevel.LOW`` for duplicate-flagged
notifications — pairs with ``format_TaskNotificationMessage``
to "ghost" the card while keeping the message in
``ctx.messages``.
"""
if self.detail == DetailLevel.LOW and content.result_is_duplicate:
return ""
if content.summary:
return (
"🔄 Async result "
f"<span class='tool-summary'>{escape_html(content.summary)}</span>"
)
if content.task_id:
return f"🔄 Async result <code>#{escape_html(content.task_id)}</code>"
return "🔄 Async result"
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring promises separator the code doesn't emit.

The docstring says the title is 🔄 Async result • <summary>, but the rendered output is 🔄 Async result <span class='tool-summary'>...</span> — no bullet. The Markdown counterpart (title_TaskNotificationMessage in markdown/renderer.py) actually emits ·. Either drop the bullet from the docstring or add one to the rendered output for parity.

✏️ Suggested code change to match the docstring
         if content.summary:
             return (
-                "🔄 Async result "
+                "🔄 Async result • "
                 f"<span class='tool-summary'>{escape_html(content.summary)}</span>"
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def title_TaskNotificationMessage(
self, content: TaskNotificationMessage, _: TemplateMessage
) -> str:
"""Title'🔄 Async result • <summary>' for an async-agent
completion notification (issue #90). The summary is the most
useful at-a-glance hint; the rest of the metadata renders in
the body card.
Empty at ``DetailLevel.LOW`` for duplicate-flagged
notificationspairs with ``format_TaskNotificationMessage``
to "ghost" the card while keeping the message in
``ctx.messages``.
"""
if self.detail == DetailLevel.LOW and content.result_is_duplicate:
return ""
if content.summary:
return (
"🔄 Async result "
f"<span class='tool-summary'>{escape_html(content.summary)}</span>"
)
if content.task_id:
return f"🔄 Async result <code>#{escape_html(content.task_id)}</code>"
return "🔄 Async result"
def title_TaskNotificationMessage(
self, content: TaskNotificationMessage, _: TemplateMessage
) -> str:
"""Title'🔄 Async result • <summary>' for an async-agent
completion notification (issue `#90`). The summary is the most
useful at-a-glance hint; the rest of the metadata renders in
the body card.
Empty at ``DetailLevel.LOW`` for duplicate-flagged
notificationspairs with ``format_TaskNotificationMessage``
to "ghost" the card while keeping the message in
``ctx.messages``.
"""
if self.detail == DetailLevel.LOW and content.result_is_duplicate:
return ""
if content.summary:
return (
"🔄 Async result "
f"<span class='tool-summary'>{escape_html(content.summary)}</span>"
)
if content.task_id:
return f"🔄 Async result <code>#{escape_html(content.task_id)}</code>"
return "🔄 Async result"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/renderer.py` around lines 779 - 801, The docstring for
title_TaskNotificationMessage promises a "•" separator but the rendered title
lacks it; update the function title_TaskNotificationMessage to include the
bullet (e.g., "🔄 Async result • ") before the summary or task_id when building
the return strings to match the docstring and the markdown renderer (or
alternatively update the docstring to remove the bullet); locate and modify the
branches that return the summary (the string built with "<span
class='tool-summary'>..."), the task_id branch (the f"🔄 Async result
<code>..."), and the default return to ensure consistent use of the "•"
separator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

It's good enough in HTML, no need for "•" separator, and we can see that as the comment trying to be clear rather than literal. And we have to stop somewhere ;-)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cboos Fair point — the tool-summary span styling already provides enough visual contrast to separate the label from the summary text, as the screenshot confirms. Closing this one out.

( ˘•ω•˘ )


🧠 Learnings used
Learnt from: cboos
Repo: daaain/claude-code-log PR: 0
File: :0-0
Timestamp: 2026-04-19T19:57:27.884Z
Learning: In `claude_code_log`, `SessionHeaderMessage` summaries (Claude-authored session titles) are intentionally preserved in `--detail user-only` output. They serve as structural/navigational scaffolding that helps downstream agents identify topic boundaries (e.g., building a `requirements.md` from a transcript). Do not flag them as assistant-authored content that should be excluded.

Learnt from: CR
Repo: daaain/claude-code-log PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-12T20:52:29.636Z
Learning: Applies to claude_code_log/renderer.py : When adding new message types or modifying CSS class generation in Python renderer, update the timeline's message type detection logic in `claude_code_log/templates/components/timeline.html` to maintain feature parity

cboos and others added 16 commits April 27, 2026 23:46
Phase 1 of dev/async-agents (issue #90). Adds the data layer
underneath the upcoming rendering work; nothing visible changes yet.

## What's new

- ``TaskOutputInput`` (Pydantic): typed input for the ``TaskOutput``
  polling tool — ``task_id`` / ``block`` / ``timeout``.
- ``TaskOutputResult`` (dataclass): the parsed XML-tagged tool result
  — ``retrieval_status``, ``task_id``, ``task_type``, ``status``,
  plus a flag + path captured from the ``[Truncated. Full output: …]``
  marker. The bulky ``<output>`` snapshot itself is **not** kept; the
  agent's full transcript already lands inline as a sidechain in our
  rendering, and the completion result reaches the trunk via the
  ``<task-notification>`` user entry.
- ``TaskNotificationUsage`` (dataclass): ``total_tokens`` /
  ``tool_uses`` / ``duration_ms``.
- ``TaskNotificationMessage`` (MessageContent): typed shape for the
  User entry Claude Code injects on async-agent completion.
  Mirrors ``TeammateMessage``'s data-layer shape: fields for
  ``task_id`` / ``status`` / ``summary``, the ``<result>`` body
  (markdown), the parsed ``<usage>``, and the trailing
  ``Full transcript available at:`` path.

## Parsers

- ``factories/tool_factory.py``: register
  ``TOOL_INPUT_MODELS["TaskOutput"] = TaskOutputInput`` and
  ``TOOL_OUTPUT_PARSERS["TaskOutput"] = parse_taskoutput_output``.
  The parser captures the four metadata tags + truncation marker;
  malformed payloads return ``None`` so the generic raw fallback
  keeps the visible content.
- ``factories/task_notification_factory.py`` (new): mirror of
  ``teammate_factory`` — ``has_task_notification`` for the cheap
  detector, ``create_task_notification_message`` for the typed
  payload. Single-tag fields, ``<result>`` block, ``<usage>``
  key:value lines, trailing transcript path. Returns ``None`` for
  empty / malformed payloads so the User card falls back to its
  default text rendering.
- ``factories/user_factory.create_user_message``: hook
  ``create_task_notification_message`` ahead of the default text
  path, right after the teammate detection. ``UserMessageContent``
  union extended.

## Plan / tracker

- ``work/async-agents.md`` carries the full 4-phase plan (this is
  Phase 1) plus data-shape notes verified against the test fixture
  ``d602eb5f-…/.jsonl`` from the clmail-monk session in #90.

## Verified

- All 1040 unit tests still pass; pyright + ruff clean.
- End-to-end on the real fixture: 4 ``TaskNotificationMessage`` and
  3 ``TaskOutput`` tool_use/tool_result pairs are parsed into the
  typed shapes (rendering still falls back to the generic
  formatters until Phase 2 lands).

Refs: #90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

Phase 2 of dev/async-agents (issue #90). Wires Phase 1's typed
models into the renderers; the user-visible cards now look right
(without the Phase 3 dedup / fold-into-anchor work yet).

## HTML

New ``html/async_formatter.py`` module (mirrors
``teammate_formatter`` style):

- ``format_taskoutput_input`` / ``format_taskoutput_output`` — minimal
  cards with ``<dl class="teammate-tool-card task-output-card">``. The
  result card shows ``retrieval_status`` / ``task_type`` / ``status``
  + a transcript-path hint when truncation was reported, and
  deliberately drops the bulky ``<output>`` snapshot (the agent's
  full transcript already lands inline as a sidechain).
- ``format_task_notification_content`` — metadata ``<dl
  class="task-notification-card">`` (task_id, status pill, usage
  fields, transcript path) + ``render_markdown_collapsible`` for the
  ``<result>`` body.

``HtmlRenderer`` adds the dispatch methods + the title formatters:

- ``title_TaskOutputInput`` → ``🔍 TaskOutput #<task_id>`` (the
  ``🔍`` short-circuits the template's default ``🛠️`` and reads as
  "look up / inspect" — distinct from the spawning ``🔧 Task``).
- ``title_TaskNotificationMessage`` → ``🔄 Async result • <summary>``.
- ``title_TaskInput`` extended: when ``run_in_background=True``,
  appends a muted ``[async]`` hint so the reader can tell async
  spawns from sync ones at a glance.

CSS additions in ``teammate_styles.css``:
- ``.task-async-hint`` (blue, muted) for the ``[async]`` title tag.
- ``.task-output-card`` (cyan border).
- ``.task-notification-card`` (blue border).

## Markdown

Mirrors the HTML in ``markdown/renderer.py``:

- ``format_TaskOutputInput`` / ``format_TaskOutputResult`` — terse
  ``key:value`` lines, transcript path appended.
- ``format_TaskNotificationMessage`` — bulleted metadata + a
  ``<details><summary>Result</summary>`` block carrying the result
  Markdown.
- ``title_TaskInput`` extended with ``*[async]*`` italic hint when
  ``run_in_background=True``.
- ``title_TaskOutputInput`` and ``title_TaskNotificationMessage``
  parallel the HTML titles.

## Verified on the clmail-monk fixture

Rendering the test JSONL produces:
- 8 ``[async]`` hints on Task tool_use titles (every async spawn).
- 4 ``🔄 Async result`` notifications.
- 3 ``🔍 TaskOutput`` polling cards.

All 1040 unit tests + 5 snapshot fixtures green; pyright + ruff
clean.

Refs: #90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of dev/async-agents (issue #90).

The async-agent flow ends with a User entry whose ``<task-
notification>`` ``<result>`` body duplicates the spawning Task's
last sidechain sub-assistant — the agent's actual answer is rendered
twice (once inline as the spawn's sidechain content, once below in
the notification card). This commit collapses the second copy to a
backlink-only stub.

## What's new

- ``TaskNotificationMessage`` gains two fields:
  - ``result_is_duplicate: bool`` — set when the renderer pass
    confirms the body matches the spawning sidechain content.
  - ``spawning_task_message_index: Optional[int]`` — the message
    index of the spawning Task's tool_use, used as a backlink
    anchor for the reader to navigate to the actual spawn.

- New ``_link_async_notifications`` pass in ``renderer.py``,
  scheduled after ``_populate_task_metadata`` (post tree-build):
  - Indexes notifications by ``task_id``.
  - Walks Task/Agent tool_results, extracting the async-agent's
    ``agent_id`` via ``_async_agent_id_from_tool_result``
    (preferring ``TaskOutput.metadata.agent_id`` set by
    ``parse_agent_result_metadata``, then ``TaskOutput.agent_id``,
    then a regex fallback on the raw text).
  - For each match: wires the spawning Task's ``pair_first``
    (tool_use index) as the backlink anchor, then walks the
    tool_result's descendants in document order via
    ``_last_sidechain_assistant_text`` and compares with
    ``_normalize_for_dedup`` against the notification's
    ``result_text``. On match, flags ``result_is_duplicate``.

- ``format_task_notification_content`` (HTML) renders a ``Spawn: ↱
  Task`` row with a ``<a class="task-notification-backlink"
  href="#msg-d-N">`` anchor, and elides the markdown body when the
  flag is set.

- ``format_TaskNotificationMessage`` (Markdown) mirrors:
  ``- **Spawn:** ↱ Task `#d-N``` line + body suppression on dup.

- New ``.task-notification-backlink`` CSS rule (blue, no underline,
  underline on hover).

## Verified on the clmail-monk fixture

All 4 notifications (a8b740b / a5de609 / a9d6832 / a70b9c2) match
their spawning Task's last sidechain sub-assistant (after fixing a
walk-order bug in the descendant traversal — the naive
``stack.pop()`` after ``stack.extend(children)`` reverses
document order; corrected to push reversed). Each notification card
now ends with ``Spawn: ↱ Task`` linked to the spawn's ``msg-d-N``,
and the duplicated markdown body is gone.

Test count holds at 1065; pyright + ruff clean.

Refs: #90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main flagged that the rendered async-agents output didn't look right
on their side. Rebasing onto current main (PR #119, user-content
markdown rendering) didn't change the rendering, but spotting the
issue did:

``TaskNotificationMessage`` is a User entry, but
``_get_message_hierarchy_level`` had no explicit branch for the
``"task_notification"`` type. It fell through to the default level
1, and since the next conversation turn is an assistant (level 2),
the assistant ended up nested as a *child* of the notification —
the notification claimed every subsequent turn as its descendant
("1 assistant + 3 tools" hanging off d-118 in the test fixture).

Conceptually the notification is more like a tool_result: it's a
delayed status update for work the previous assistant initiated, not
a new user turn that the next assistant is responding to. Place it
at level 3 explicitly:

- Pops anything ≥ 3 from the stack but keeps the level-2 spawning
  assistant on top.
- Sits as a sibling of the spawning assistant's tool_use/tool_result
  entries.
- Subsequent level-2 assistants pop the notification (≥ 2) and
  start a fresh turn — siblings of the spawning assistant, not
  descendants of the notification.

Verified on the clmail-monk fixture: d-118 (a8b740b notification)
now renders with zero descendants, and the following assistant d-119
becomes its sibling under their shared parent d-11.

Doc: ``dev-docs/FOLD_STATE_DIAGRAM.md`` level table updated to call
out ``task_notification`` alongside the other Level-3 types.

Refs: #90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main flagged that Phase 3 only got the *notification* dedup right —
the spawning Task tool_result still showed only "Async agent
launched successfully", and the agent's actual answer remained
buried at the tail of the relocated sidechain. The reader had to
scroll past the agent's working tools (Reads, Bash) to find the
final summary.

This commit closes the gap: the agent's last sub-assistant content
folds *into* the spawning Task's tool_result rendering, with the
matching sidechain message removed so the answer appears exactly
once at the natural reading position.

## Mechanics

- ``TaskOutput`` gains ``async_final_answer: Optional[str]``.
  Populated by ``_link_async_notifications`` on every async-agent
  match; ``None`` for sync Tasks.
- ``_last_sidechain_assistant`` (replaces ``…_text``) returns
  ``(msg, parent, index)`` so the caller can both inspect the text
  and ``del parent.children[index]`` — same pattern
  ``_cleanup_sidechain_duplicates`` uses for sync Tasks.
- ``_link_async_notifications`` now does three things on each match:
  copies the answer onto ``TaskOutput.async_final_answer``, drops
  the duplicate sub-assistant from the sidechain tree, and flags
  the notification body as duplicate (with the spawn backlink).
  Three views — spawn / sidechain / notification — converge on a
  single visible copy at the spawn.
- ``format_task_output`` (HTML) renders the folded answer as a
  ``<div class="task-async-answer-label">Result <small>(from async
  notification)</small></div>`` followed by the answer in a
  ``render_markdown_collapsible`` block.
- ``format_TaskOutput`` (Markdown) appends a second
  ``<details><summary>Result (from async notification)</summary>``
  block.
- New ``.task-async-answer-label`` CSS rule.

## Verified on the clmail-monk fixture

All 4 async Task tool_results (a8b740b / a5de609 / a9d6832 /
a70b9c2) now render:

- their original "Async agent launched successfully" stub,
- followed by the "Result (from async notification)" fold,
- followed by the collapsible agent answer.

Sub-assistant count drops by 4 (the duplicates removed from the
sidechain trees). Notification cards stay backlink-only.

Refs: #90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-answer fold over its label

The .tool_result .collapsible-code rule applies margin-top:-2.5em to
tuck the *first* collapsible under the tool title. Inside an async-task
fold, the second collapsible (.task-async-answer) was also caught by it
and overlapped the "Result (from async notification)" label. Reset the
margin only on .task-async-answer .collapsible-code; the first .task-
result collapsible keeps its tucked-up alignment.

Snapshots refreshed for the CSS bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a synthetic fixture sliced down from the canonical clmail-monk
session (`d602eb5f-…`) and a focused test module exercising the new
async-agents pipeline end-to-end:

- `test/test_data/async_agents/` — 7-entry main session + 3-entry
  sidechain. Covers the four pieces the renderer has to handle:
  `Task` with `run_in_background=true`, the canonical
  `Async agent launched successfully\nagentId: …` tool_result,
  a `TaskOutput` poll with `<retrieval_status>/<task_id>/<output>
  [Truncated…]` shape, and a `<task-notification>` whose `<result>`
  matches the last sub-assistant verbatim.

- `test/test_async_agents.py` — 25 tests:
  * `has_task_notification` and `create_task_notification_message`
    parser coverage (positive + edge cases: empty, missing usage,
    partial usage).
  * `parse_taskoutput_output` coverage (full payload, in-progress
    status without `<output>`, non-TaskOutput rejection).
  * Dispatch-table assertions (defensive against accidental churn).
  * Fixture loading and factory dispatch.
  * Phase 3 rendering pipeline assertions:
    - notification flagged `result_is_duplicate=True`
    - `TaskOutput.async_final_answer` populated on the spawning Task
    - duplicate sub-assistant dropped from the rendered tree
    - the agent's final answer text appears exactly once across
      the entire tree (folded under the spawn).

- Snapshot coverage in both `test_snapshot_html.py` and
  `test_snapshot_markdown.py` for the new fixture, locking in:
  the `*[async]*` hint badge on the Task title, the
  ``Async agent launched successfully`` stub, the
  ``Result (from async notification)`` fold, the TaskOutput poll
  card, and the notification-collapsed-to-backlink stub.

Verified: 1115 unit tests pass, pyright + ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 3 fold disappeared at `--detail low`: the linker walked the
sidechain looking for the last sub-assistant to fold, but
`_filter_by_detail` had already dropped sidechain entries pre-render.
With nothing to match against, the fold was skipped and the agent's
final answer was buried in the (now-collapsed) `<task-notification>`
card.

Plan A splits the pass:

- **Spawn-fold (FULL/HIGH/LOW):** when a notification's `task_id`
  matches the spawning Task tool_result's `agent_id`, fold the
  notification's `result_text` directly onto
  `TaskOutput.async_final_answer` and flag the notification
  `result_is_duplicate`. Sidechain text is no longer required for the
  fold itself — the notification body is the canonical source.

- **Sidechain dedup (FULL/HIGH only):** when the last sub-assistant
  text matches the notification's `result_text`, drop it from the
  tree. This is the only piece that needs the sidechain — at LOW the
  sidechain is gone and there's nothing to remove anyway.

- **MINIMAL/USER_ONLY:** the spawn fold is skipped (the spawning
  Task tool_result is dropped post-render — there's nothing to fold
  onto). The notification card retains its body so the agent's
  answer stays visible.

`_link_async_notifications` now takes the active `DetailLevel` so it
can decide whether the spawn target survives.

Tests:
- 5 new parametrized cases in `TestAsyncAgentsDetailLevels` cover
  FULL/HIGH/LOW (fold present + notification flagged duplicate) and
  MINIMAL/USER_ONLY (no fold + notification body kept visible).
- New `test_async_agents_fixture_html_low` snapshot locks in the
  rendered LOW shape — guard against silent regressions to the fold
  pipeline at that detail level.

Verified: 1121 unit tests pass, pyright + ruff clean. Fold count
on the canonical clmail-monk fixture: 4 at FULL/HIGH/LOW (was 0 at
LOW before the fix), 0 at MINIMAL/USER_ONLY (notification body is
the surviving copy at those levels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
At LOW the spawn-fold survives (Plan A), so the standalone
`<task-notification>` card duplicates the answer the reader is
already seeing folded under the spawning Task tool_result. FULL/HIGH
keep the card for transcript fidelity; MINIMAL/USER_ONLY don't flag
duplicates (the Task tool_result is filtered there, so the
notification body is the surviving copy).

Implementation note: main's mail #2631 suggested adding the rule
inside `_filter_template_by_detail`. That filter runs before
`_link_async_notifications` in the rendering pipeline (line 754 vs
826 in renderer.py), so `result_is_duplicate` is still False when
the filter visits each message. Instead, a small post-link pass
`_drop_duplicate_notifications_at_low` runs right after the linker
when `detail == LOW`. Survivors are remapped via the existing
`_reindex_filtered_context`; tree children are pruned so the
notification doesn't linger as a sub-message of its parent.

Tests:
- New `test_duplicate_notification_dropped_at_low` confirms the
  notification is gone from `ctx.messages` at LOW.
- New `test_notification_flagged_duplicate_at_full_and_high` keeps
  the assertions previously bundled into the LOW test (notification
  remains in ctx, flagged duplicate, with backlink wired).
- Existing detail-level parametrized cases retained.

Snapshot: `test_async_agents_fixture_html_low` regenerated to lock
in the new LOW shape — fold visible at the spawn, no notification
card.

Verified: 1123 unit tests pass, pyright + ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runtime message-type filter in transcript.html (line 484) hides any
message whose CSS classes don't include one of the toolbar's known
types: `user, system, assistant, thinking, tool_use, tool_result,
sidechain, image`. `TaskNotificationMessage` was missing from
`CSS_CLASS_REGISTRY`, so `css_class_from_message` fell back to bare
`msg.type` = `task_notification` — matching no toolbar type, hence
permanently flagged `filtered-hidden` even when "All filters" was
active.

Register the content type with the same shape as the other user-
variant entries (`UserSteeringMessage: ["user", "steering"]`,
`TeammateMessage: ["user", "teammate"]`, etc.). The notification's
underlying JSONL entry is a plain User message — Claude Code injects
it as `type: "user"` with the `<task-notification>` block in
`message.content` — so the User toggle controlling its visibility
is the natural mapping.

Snapshot impact: the FULL async-agents fixture's notification div
class string changes from `task_notification` to `user task_notification`
(single-line diff). The LOW snapshot is unchanged because the
duplicate notification is dropped pre-template by
`_drop_duplicate_notifications_at_low`.

Verified: 1123 unit tests pass, pyright + ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… at LOW

`_drop_duplicate_notifications_at_low` calls `_reindex_filtered_context`
to remap message_index after removing the dropped notifications, but
that helper *clears* every message's pair_first/pair_middle/pair_last
on the assumption the caller will re-run pair identification.
`_identify_message_pairs` runs at renderer.py:775 — before
`_link_async_notifications` (826) and the drop pass (829) — so nothing
re-establishes the pairs the helper just cleared.

This broke two LOW-only behaviors:

- **Markdown LOW**: tool_use renders Instructions but not Report or
  the async-fold "Result (from async notification)" body. The
  Markdown renderer's `_render_message` only emits a tool_result
  body when its tool_use is `is_first_in_pair` (renderer.py:1417);
  without `pair_last` set, the body is dropped, and the tool_result
  has no `title_ToolResultMessage` to render itself.

- **HTML LOW**: Task tool_use and tool_result render with a visual
  gap because the `pair_first`/`pair_last` CSS classes that flush
  adjacent cards together are absent.

Fix: re-run `_identify_message_pairs(ctx.messages)` immediately
after `_reindex_filtered_context` in the drop pass. Pairs are
reconstructed from scratch via the standard two-pass algorithm.

Verified on the canonical clmail-monk fixture at LOW: 7
`pair_first`/`pair_last` HTML divs (Task tool_use ↔ tool_result),
Markdown shows Instructions + Report + "Result (from async
notification)" for every async Task. The LOW snapshot diff is
limited to two added pair classes on the synthetic fixture's Task
tool_use and tool_result.

Verified: 1123 unit tests pass, pyright + ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ty check` flagged four `# type: ignore[no-untyped-def]` comments in
test/test_async_agents.py as unused — the inferred parameter types
already satisfy ty's checks. Carried over from when I sketched the
helpers without type annotations; no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… naming

- New `dev-docs/agents.md` covers all three flavors of Task-spawned
  agents (sync sub-agents #79, async task agents #90, teammates #91).
  The async-agents § is the new detail: pipeline shape diagram,
  the two `TaskOutput`-named dataclasses (`TaskOutput` on the Task
  tool_result vs `TaskOutputResult` on the polling tool), the
  Phase 3 fold mechanics, the per-detail-level visibility matrix,
  key files, and the test fixture pointer.

- `dev-docs/messages.md` gains:
  - A `TaskOutput` polling-tool row in the Tool Results table and
    the Available Tools matrix (was previously absent).
  - A note on the `TaskOutput` vs `TaskOutputResult` name collision,
    forwarding to agents.md § 2.2.
  - A new "Async Task Notification" subsection under user content
    documenting `TaskNotificationMessage` (Phase 3 dedup markers
    included), forwarding to agents.md § 2 for the end-to-end flow.

- `dev-docs/teammates.md` § 10.1 ("Standard sub-agents and async
  task agents") now reflects that #90 is shipped — points readers
  at agents.md § 2 for the as-built reference. The doc's top-of-
  file companion-doc list and References block both gain agents.md.

Surfaced by user feedback on PR #132: the `TaskOutput` ↔
`TaskOutputResult` name collision is genuinely confusing; documenting
the distinction in the canonical message-type reference + a
focused agents.md should keep future readers (and Claude) on track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tput

CodeRabbit review on PR #132: `_link_async_notifications` set
`notification.result_is_duplicate = True` whenever a Task tool_result
matched a notification by `agent_id`, even when `content.output` was
not a parsed `TaskOutput`. `_async_agent_id_from_tool_result` has
three pathways — the third (regex fallback on raw text) supports
shapes the parser couldn't structure into a `TaskOutput`. On those
shapes we'd skip the actual fold (no `async_final_answer` field to
write into) but still suppress the notification body, silently losing
the agent's only visible answer.

Move the duplicate flag inside the `isinstance(content.output,
TaskOutput)` guard. The notification body now stays visible whenever
the fold can't land — preserving "answer visible at least once" at
every detail level.

Also rebuild `session_nav` after `_drop_duplicate_notifications_at_low`
runs at LOW. The drop pass remaps `ctx.session_first_message` indices,
but `session_nav` was built earlier with the pre-drop indices baked
into its `message_index` and `parent_message_index` fields. The
single-session canonical fixture didn't surface this (notifications
sit after the only session header, so dropping them doesn't shift the
header), but a multi-session transcript with notifications between
session headers would land nav anchors one (or more) message slot off
after the LOW reindex. Reuses the same `prepare_session_navigation`
call with the up-to-date ctx.

Verified: 1123 unit tests pass, pyright + ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…meline

CodeRabbit review on PR #132 flagged two paired issues:

- Naming convention: every other modifier in `CSS_CLASS_REGISTRY`
  uses hyphens (`system-hook`, `slash-command`, `command-output`,
  `bash-input`, `bash-output`); the actual CSS rules in
  `teammate_styles.css` also use hyphens
  (`task-notification-card`, `task-notification-backlink`). The
  registry entry I added for `TaskNotificationMessage` was the
  underscored `"task_notification"` — no `.task_notification` CSS
  rule exists, making the underscore version dead.

- Timeline misclassification: `timeline.html` only knew the seven
  toolbar types plus `teammate`/`sidechain`/etc. With both `user`
  and `task-notification` classes on the rendered div, the
  generic `.find()` returned `user` first — async notifications
  landed in the User row of the timeline rather than getting their
  own group.

Rename the modifier to `task-notification` (Python message-type
identifier `"task_notification"` in `models.py` / `renderer.py`
unchanged — that's a separate code identifier), add a
`task-notification` entry to `messageTypeGroups` in `timeline.html`,
and add a `classList.includes('task-notification')` branch ahead of
the generic `.find()` (mirroring the existing `teammate` branch, for
the same reason: both carry the `user` class). The new group is
inserted in the timeline's `groupOrder` between `teammate` and
`system`.

Snapshot: HTML async-agents fixture now renders the div as
`message user task-notification …`; LOW snapshot still drops the
duplicate-flagged notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit review on PR #132 found a third remap cascade — after
session_nav and pair refs, `TemplateMessage.ancestry` and the
`spawning_task_message_index` / `parent_message_index` backlink
fields are also frozen at hierarchy-build time, so any reindex
after the tree is built leaves them pointing at stale slots. The
"each fix unblocks the next bug" pattern was a sign the approach
was wrong.

Switch to "ghosting": the duplicate notification stays in
`ctx.messages` with its original `message_index`. Only its
*rendered output* disappears at LOW. Implementation: gate
`format_TaskNotificationMessage` and `title_TaskNotificationMessage`
in both `HtmlRenderer` and `MarkdownRenderer` on
`self.detail == LOW and content.result_is_duplicate` → return
`""`. The rendering loop's existing "skip empty messages" elision
(HTML: `if title or html or msg.children:`; Markdown:
`_render_message` returning `""` when there's no title and no
content) drops the entry from the visible output without touching
ancestry classes, backlinks, session nav, or pair refs.

This deletes:

- `_drop_duplicate_notifications_at_low` (the survivor list +
  `_reindex_filtered_context` + `_identify_message_pairs` re-run
  + tree-children prune).
- The post-link call site at the end of `generate_template_messages`.
- The `session_nav` rebuild that the reindex required.
- The `_identify_message_pairs` re-run that the pair-clear required.

Net: -88 lines in `renderer.py`, +42 across the two formatter
gates, no behavior change in the rendered HTML/Markdown at any
detail level.

Test refresh: `test_duplicate_notification_dropped_at_low` →
`test_duplicate_notification_ghosted_at_low`. Asserts the
notification is *still* in `ctx.messages` with
`result_is_duplicate=True`, and that both `HtmlRenderer` and
`MarkdownRenderer` return `""` for its title and body when
configured at LOW.

LOW snapshot regenerated: `message_id` indices for messages after
the (now ghosted) notification stay at their original values
instead of shifting down by one. Visible rendered output is
otherwise byte-equal.

Verified: 1123 unit tests pass, pyright + ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos cboos force-pushed the dev/async-agents branch from ee263ed to e9f5ead Compare April 27, 2026 21:49
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/factories/task_notification_factory.py`:
- Around line 114-122: The current code runs _FIELD_RE.finditer over the entire
body so any tags inside the <result> or <usage> sections can clobber real
metadata; change the parsing order to first locate result_match =
_RESULT_RE.search(body) and usage_match = _USAGE_RE.search(body), compute a
cutoff index = min(start of result_match, start of usage_match) if present (or
len(body) otherwise), then run _FIELD_RE.finditer on body[:cutoff] to populate
fields; continue to extract result_text from result_match and usage via
_parse_usage(usage_match.group("body")) as before. Ensure you reference fields,
_FIELD_RE, result_match/_RESULT_RE, usage_match/_USAGE_RE and _parse_usage when
making the change.

In `@claude_code_log/renderer.py`:
- Around line 2293-2298: The early check that skips entries when
content.tool_name is not in ("Task", "Agent") prevents
`_async_agent_id_from_tool_result(content)` from recovering async agent IDs and
causes orphaned notifications; remove or move that `tool_name` filter so you
always call `_async_agent_id_from_tool_result(content)` for any
`ToolResultMessage` and only skip when that call returns None (or after applying
the same fallback used by `_relocate_subagent_blocks()`); ensure you still only
process instances of `ToolResultMessage` and preserve existing logic that uses
the recovered `agent_id` to fold notifications back into the spawn.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1780624b-f07f-466c-8951-8d3dcf02f1da

📥 Commits

Reviewing files that changed from the base of the PR and between ee263ed and e9f5ead.

📒 Files selected for processing (25)
  • claude_code_log/factories/task_notification_factory.py
  • claude_code_log/factories/tool_factory.py
  • claude_code_log/factories/user_factory.py
  • claude_code_log/html/async_formatter.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/html/templates/components/timeline.html
  • claude_code_log/html/tool_formatters.py
  • claude_code_log/html/utils.py
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/FOLD_STATE_DIAGRAM.md
  • dev-docs/agents.md
  • dev-docs/messages.md
  • dev-docs/teammates.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_async_agents.py
  • test/test_data/async_agents/README.md
  • test/test_data/async_agents/eb000000-0000-4000-8000-000000000001.jsonl
  • test/test_data/async_agents/eb000000-0000-4000-8000-000000000001/subagents/agent-cccc333.jsonl
  • test/test_snapshot_html.py
  • test/test_snapshot_markdown.py
  • work/async-agents.md
✅ Files skipped from review due to trivial changes (4)
  • test/test_data/async_agents/eb000000-0000-4000-8000-000000000001/subagents/agent-cccc333.jsonl
  • dev-docs/FOLD_STATE_DIAGRAM.md
  • dev-docs/agents.md
  • dev-docs/messages.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • claude_code_log/html/utils.py
  • test/test_snapshot_markdown.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/factories/tool_factory.py
  • test/snapshots/test_snapshot_markdown.ambr
  • claude_code_log/html/async_formatter.py
  • test/test_async_agents.py

Comment thread claude_code_log/factories/task_notification_factory.py Outdated
Comment thread claude_code_log/renderer.py
cboos and others added 3 commits April 27, 2026 23:59
…h ghosting

Surfaced during the PR #132 review pass: every "drop messages and
reindex" pass that adds new index-bearing fields creates a fresh
"remember to remap X" trap. The async-agents PR hit three in
succession (pair refs, session_nav, ancestry/backlinks) before
switching to ghosting. PR #131 added a fourth remap target
(`SessionHeaderMessage.parent_message_index`).

The note records the architectural assessment of generalizing
ghosting to the two existing `_reindex_filtered_context` callers
(`_pair_skill_tool_uses` — easy, same shape as the async-agents
fix; `_filter_template_by_detail` — medium refactor, needs
tree-build child grafting + pair-id skip + render-loop elision
flag), and proposes a migration path that ends with deleting
`_reindex_filtered_context` entirely.

Not work for this PR — captured here so the design rationale is
preserved when someone (likely Claude later) picks it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit review on PR #132: ``_FIELD_RE`` (the regex matching
``<task-id>``, ``<status>``, ``<summary>``) was running over the
full notification body, including the ``<result>`` payload. The
``<result>`` body is agent-authored markdown and frequently
contains literal HTML/XML — an agent quoting a ``<summary>`` tag
verbatim, for instance, would clobber the real notification
``<summary>`` field. Same risk for ``<status>`` and ``<task-id>``.
Downstream this poisons the fold/dedup path: the spawning Task
tool_result wouldn't match the right notification, and the wrong
status badge would render on the card.

Extract ``<result>`` and ``<usage>`` first, strip their full
match strings from the search surface, then run ``_FIELD_RE``
over the residual header. The result body is unchanged — it
still ships the agent's verbatim XML-ish content.

New regression test: a notification whose ``<result>`` includes
``<task-id>fake999</task-id> <status>failed</status>
<summary>Bogus summary</summary>`` must not overwrite the
real ``real123`` / ``completed`` / ``Real summary`` header
metadata. Inline tags are preserved verbatim in ``result_text``.

Verified: 1129 unit tests pass (33 in test_async_agents),
pyright + ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit review on PR #132: ``_link_async_notifications`` skipped
every tool_result whose ``tool_name`` was not exactly ``"Task"`` or
``"Agent"`` before even calling ``_async_agent_id_from_tool_result``.
``tool_name`` is populated by pair-id, which can leave a tool_result
orphaned in fork/branch shapes where the spawning tool_use sits in
a different branch — yet that orphan still carries the canonical
``agentId:`` line, so the notification ought to fold onto it.

Drop the unconditional pre-filter. After the agent-id detector
returns a hit, gate the non-Task/Agent path on a stronger signal —
a parsed ``TaskOutput`` output OR an ``agentId`` already tagged on
the entry's meta — so an unrelated tool_result that happens to
mention "agentId:" in its raw text doesn't hijack a notification
meant for a real spawn.

The canonical path (paired Task/Agent tool_result with parsed
``TaskOutput``) is unaffected; only the fallback fork/branch case
gains coverage. No behavior change on any existing fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos cboos merged commit 294ed3b into main Apr 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant