diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 951c751e..62f9d250 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -220,6 +220,46 @@ def get_library_version() -> str: # ========== Cache Path Configuration ========== +def subagents_fingerprint(jsonl_path: Path) -> str: + """Fingerprint of the sidecar inputs that feed spawn discovery (#213). + + Parsing a transcript also reads ``agent-*.meta.json`` sidecars (see + ``converter._subagent_meta_map``), so they must take part in cache + invalidation: a sidecar appearing after the transcript was cached + (e.g. a still-running agent spawning a child without touching the + trunk file) would otherwise go unnoticed by the mtime-only check. + + The fingerprint is ``":"`` over the sidecars + of the dirs where this transcript's children can live — the sibling + ``/subagents/`` dir, plus the containing dir for agent files + (the flat layout puts children next to their parent). Sidecars are + written once at spawn time, so count+newest-mtime captures every + addition and removal. Empty string when there are none. + + Deliberately narrower than ``converter._subagent_meta_map``'s scan + for TRUNK files: the parse also defensively checks the project dir + itself, but Claude Code never writes sidecars there, and + fingerprinting it would rescan a potentially large dir on every + cache read for a theoretical-only input. + """ + candidates = [jsonl_path.parent / jsonl_path.stem / "subagents"] + if jsonl_path.parent.name == "subagents": + candidates.append(jsonl_path.parent) + metas: list[Path] = [] + for directory in candidates: + if directory.is_dir(): + metas.extend(directory.glob("agent-*.meta.json")) + if not metas: + return "" + try: + newest = max(int(p.stat().st_mtime) for p in metas) + except OSError: + # A sidecar vanished mid-scan; treat as unstable so the next + # check re-reads (an always-mismatching fingerprint is safe). + return f"{len(metas)}:unstable" + return f"{len(metas)}:{newest}" + + def get_cache_db_path(projects_dir: Path) -> Path: """Get cache database path, respecting CLAUDE_CODE_LOG_CACHE_PATH env var. @@ -471,7 +511,8 @@ def is_file_cached(self, jsonl_path: Path) -> bool: with self._get_connection() as conn: row = conn.execute( - "SELECT source_mtime FROM cached_files WHERE project_id = ? AND file_name = ?", + "SELECT source_mtime, subagents_fingerprint FROM cached_files" + " WHERE project_id = ? AND file_name = ?", (self._project_id, jsonl_path.name), ).fetchone() @@ -482,7 +523,19 @@ def is_file_cached(self, jsonl_path: Path) -> bool: cached_mtime = row["source_mtime"] # Cache is valid if modification times match (within 1 second tolerance) - return abs(source_mtime - cached_mtime) < 1.0 + if abs(source_mtime - cached_mtime) >= 1.0: + return False + + # The sidecar inputs of spawn discovery (#213) must match too — + # new agent-*.meta.json files appear without touching the source + # jsonl. Pre-007 rows carry NULL: accept those only when the file + # has no sidecars today (nothing to miss), so legacy caches don't + # mass-invalidate while sessions WITH sidecars reparse once. + cached_fp = row["subagents_fingerprint"] + current_fp = subagents_fingerprint(jsonl_path) + if cached_fp is None: + return current_fp == "" + return cached_fp == current_fp def load_cached_entries(self, jsonl_path: Path) -> Optional[List[TranscriptEntry]]: """Load cached transcript entries for a JSONL file.""" @@ -564,14 +617,27 @@ def load_cached_entries_filtered( return [self._deserialize_entry(row) for row in rows] def save_cached_entries( - self, jsonl_path: Path, entries: List[TranscriptEntry] + self, + jsonl_path: Path, + entries: List[TranscriptEntry], + subagents_fp: Optional[str] = None, ) -> None: - """Save parsed transcript entries to cache.""" + """Save parsed transcript entries to cache. + + ``subagents_fp`` is the sidecar fingerprint AS OF THE PARSE — + callers that scanned sidecars should pass the value captured + before the scan, so a sidecar landing mid-parse mismatches on the + next read and forces a reparse (over-invalidation; computing it + here at save time would instead validate a parse that never saw + the late sidecar). Falls back to computing now when omitted. + """ if self._project_id is None: return source_mtime = jsonl_path.stat().st_mtime cached_mtime = datetime.now().timestamp() + if subagents_fp is None: + subagents_fp = subagents_fingerprint(jsonl_path) with self._get_connection() as conn: # Insert or update file record @@ -579,13 +645,15 @@ def save_cached_entries( conn.execute( """ INSERT INTO cached_files - (project_id, file_name, file_path, source_mtime, cached_mtime, message_count) - VALUES (?, ?, ?, ?, ?, ?) + (project_id, file_name, file_path, source_mtime, cached_mtime, + message_count, subagents_fingerprint) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, file_name) DO UPDATE SET file_path = excluded.file_path, source_mtime = excluded.source_mtime, cached_mtime = excluded.cached_mtime, - message_count = excluded.message_count + message_count = excluded.message_count, + subagents_fingerprint = excluded.subagents_fingerprint """, ( self._project_id, @@ -594,6 +662,7 @@ def save_cached_entries( source_mtime, cached_mtime, len(entries), + subagents_fp, ), ) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index e44302a0..6f7b555d 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -229,15 +229,21 @@ def load_transcript( to_date: Optional[str] = None, silent: bool = False, _loaded_files: Optional[set[Path]] = None, + _meta_maps: Optional[dict[Path, dict[str, str]]] = None, ) -> list[TranscriptEntry]: """Load and parse JSONL transcript file, using cache if available. Args: _loaded_files: Internal parameter to track loaded files and prevent infinite recursion. + _meta_maps: Internal per-load memo of ``{dir: {toolUseId: agentId}}`` + sidecar maps, so one flat ``subagents/`` family is scanned once + per top-level load instead of once per recursively loaded file. """ # Initialize loaded files set on first call if _loaded_files is None: _loaded_files = set() + if _meta_maps is None: + _meta_maps = {} # Prevent infinite recursion by checking if this file is already being loaded if jsonl_path in _loaded_files: @@ -259,7 +265,14 @@ def load_transcript( print(f"Loading {jsonl_path} from cache...") return cached_entries - # Parse from source file + # Parse from source file. Capture the sidecar fingerprint FIRST — it + # must describe the world as-of-the-parse (or older), so a sidecar + # landing mid-parse mismatches on the next cache read and forces a + # reparse, instead of being fingerprinted-as-covered without having + # been scanned (review advisory on PR #218). + from .cache import subagents_fingerprint + + subagents_fp = subagents_fingerprint(jsonl_path) messages: list[TranscriptEntry] = [] agent_ids: set[str] = set() # Collect agentId references while parsing # Track unrecognized message types already warned about so we emit at @@ -383,6 +396,16 @@ def load_transcript( f"\n{traceback.format_exc()}" ) + # Sidecar-driven spawn linking (issue #213): resolve each spawning + # tool_use to its sub-agent via the agent-.meta.json files. This is + # what makes NESTED spawns discoverable — a sub-agent's own spawn + # tool_results carry no ``toolUseResult.agentId`` (trunk-only + # enrichment), and an interrupted spawn has no usable tool_result at + # all; the sidecar's ``toolUseId`` covers both. + _apply_subagent_meta_links( + messages, _subagent_meta_map(jsonl_path, _meta_maps), agent_ids, jsonl_path + ) + # Prompt-hash fallback: link Task tool_results that lack a structured # agentId (common for true teammate subagents) by matching the # tool_use's prompt input against the dict[str, str]: + """``{spawning toolUseId: agentId}`` from ``agent-.meta.json`` sidecars. + + Claude Code (2.1.172+) writes every sub-agent transcript flat into the + trunk session's ``/subagents/`` dir — at ANY nesting depth — with a + sidecar ``agent-.meta.json`` carrying the spawning ``toolUseId``. + The map covers the whole flat family; callers match it against the + tool_use ids actually present in their own entries. + + Children of *this* transcript can live in two places: the sibling + ``/subagents/`` dir (trunk file) or the transcript's own + containing dir (agent file — the flat layout puts children next to + their parent). Scans both, memoized per directory in ``memo`` (scoped + to one top-level load, so there's no cross-load staleness). + """ + result: dict[str, str] = {} + candidates = [jsonl_path.parent / jsonl_path.stem / "subagents", jsonl_path.parent] + for directory in candidates: + if directory in memo: + result.update(memo[directory]) + continue + dir_map: dict[str, str] = {} + if directory.is_dir(): + for meta_path in directory.glob("agent-*.meta.json"): + agent_id = meta_path.name[len("agent-") : -len(".meta.json")] + if not agent_id: + continue + try: + raw: Any = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(raw, dict): + continue + tool_use_id = cast("dict[str, Any]", raw).get("toolUseId") + if isinstance(tool_use_id, str) and tool_use_id: + dir_map[tool_use_id] = agent_id + memo[directory] = dir_map + result.update(dir_map) + return result + + +def _apply_subagent_meta_links( + messages: list[TranscriptEntry], + meta_map: dict[str, str], + agent_ids: set[str], + jsonl_path: Path, +) -> None: + """Stamp ``spawnedAgentId`` on spawn entries resolved via sidecar files. + + For every sidecar ``toolUseId`` whose tool_use appears in *messages*, + the spawned agent id lands on the spawning tool_result entry (or the + tool_use's assistant entry when no result exists — interrupted or + still-running spawns) and joins *agent_ids* so the file loader picks + the transcript up. On non-sidechain (trunk) entries the legacy + ``agentId`` reference is backpatched too, so everything keyed on it + (relocation anchors, task metadata) keeps working; sidechain entries + keep ``agentId`` as pure membership. + """ + if not meta_map: + return + own_agent_id = ( + jsonl_path.stem[len("agent-") :] + if jsonl_path.stem.startswith("agent-") + else None + ) + use_entries: dict[str, BaseTranscriptEntry] = {} + result_entries: dict[str, BaseTranscriptEntry] = {} + for msg in messages: + if not isinstance(msg, (UserTranscriptEntry, AssistantTranscriptEntry)): + continue + for item in msg.message.content: + if isinstance(item, ToolUseContent) and item.id in meta_map: + use_entries.setdefault(item.id, msg) + elif isinstance(item, ToolResultContent) and item.tool_use_id in meta_map: + result_entries.setdefault(item.tool_use_id, msg) + + for tool_use_id, spawned in sorted(meta_map.items()): + # Self-guard: an agent can't spawn itself; skip a sidecar that + # (through data corruption) would claim otherwise. + if spawned == own_agent_id: + continue + anchor = result_entries.get(tool_use_id) or use_entries.get(tool_use_id) + if anchor is None: + # Sidecar belongs to another transcript of the flat family. + continue + if anchor.spawnedAgentId and anchor.spawnedAgentId != spawned: + # One anchor entry, two spawns: only reachable when a single + # entry carries several spawn tool_uses AND more than one of + # them lacks a tool_result (results anchor 1:1 on their own + # entries). Claude Code streams one content block per + # assistant entry, so this doesn't occur in real transcripts + # — but never silently overwrite: keep the first link and + # let the extra transcript load anyway (it relocates via the + # defensive tail-append instead of a spawn anchor). + agent_ids.add(spawned) + continue + anchor.spawnedAgentId = spawned + if not anchor.isSidechain and not anchor.agentId: + anchor.agentId = spawned + agent_ids.add(spawned) + + def _link_subagents_by_prompt_hash( messages: list[TranscriptEntry], jsonl_path: Path, @@ -632,9 +771,17 @@ def _integrate_agent_entries(messages: list[TranscriptEntry]) -> None: agent_anchors: dict[str, str] = {} agent_anchors_from_sidechain: dict[str, str] = {} + spawn_anchors: dict[str, str] = {} for msg in messages: if not isinstance(msg, (BaseTranscriptEntry, PassthroughTranscriptEntry)): continue + # Sidecar-resolved spawn links (issue #213) are the most precise + # anchors — they work at any nesting depth and need no boundary + # heuristics (an entry's own membership can never equal the agent + # it spawned). + spawned = getattr(msg, "spawnedAgentId", None) + if spawned and spawned != msg.agentId: + spawn_anchors.setdefault(spawned, msg.uuid) if not msg.agentId: continue if msg.isSidechain: @@ -648,9 +795,11 @@ def _integrate_agent_entries(messages: list[TranscriptEntry]) -> None: agent_anchors_from_sidechain.setdefault(msg.agentId, msg.uuid) else: agent_anchors[msg.agentId] = msg.uuid - # Merge: non-sidechain anchors take priority + # Merge precedence: sidecar spawn links > non-sidechain agentId + # references > sidechain cross-boundary heuristics. for agent_id, uuid in agent_anchors_from_sidechain.items(): agent_anchors.setdefault(agent_id, uuid) + agent_anchors.update(spawn_anchors) if not agent_anchors: return diff --git a/claude_code_log/factories/meta_factory.py b/claude_code_log/factories/meta_factory.py index 1717bba8..3e9b7d85 100644 --- a/claude_code_log/factories/meta_factory.py +++ b/claude_code_log/factories/meta_factory.py @@ -29,6 +29,7 @@ def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta: is_meta=getattr(transcript, "isMeta", False) or False, source_tool_use_id=getattr(transcript, "sourceToolUseID", None), agent_id=transcript.agentId, + spawned_agent_id=transcript.spawnedAgentId, cwd=transcript.cwd, git_branch=transcript.gitBranch, team_name=getattr(transcript, "teamName", None), diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index 99664e26..c73c4d7f 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1530,16 +1530,24 @@ details summary { border-left: 0px solid var(--tool-use-color); } -/* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing +/* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ -.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ +.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), +.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -1568,7 +1576,8 @@ details summary { .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } diff --git a/claude_code_log/migrations/007_subagents_fingerprint.sql b/claude_code_log/migrations/007_subagents_fingerprint.sql new file mode 100644 index 00000000..a4c2f436 --- /dev/null +++ b/claude_code_log/migrations/007_subagents_fingerprint.sql @@ -0,0 +1,16 @@ +-- Nested sub-agent discovery: sidecar inputs join cache invalidation +-- Migration: 007 +-- Description: Add a `subagents_fingerprint` column to `cached_files`. +-- Since #213, parsing a transcript also reads the sibling +-- `subagents/agent-*.meta.json` sidecars (spawn discovery); a sidecar +-- appearing AFTER the transcript was cached would otherwise go +-- unnoticed, because invalidation compared the source jsonl's mtime +-- only. The fingerprint (sidecar count + newest sidecar mtime) is +-- stored at save time and compared on every cache read. +-- +-- Backward-compatible: existing rows get NULL via SQLite's column-add +-- default. A NULL fingerprint counts as valid only when the file has +-- no sidecars today — cached files WITH sidecars reparse once to pick +-- up the spawn links, everything else stays cached. + +ALTER TABLE cached_files ADD COLUMN subagents_fingerprint TEXT; diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 5ea9e6b0..795cb107 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -208,6 +208,21 @@ class BaseTranscriptEntry(BaseModel): agentId: Optional[str] = None # Agent ID for sidechain messages gitBranch: Optional[str] = None # Git branch name when available teamName: Optional[str] = None # Active team name (teammates feature) + # Synthetic (set by the loader, never by Claude Code): the id of the + # sub-agent spawned by this entry's Agent/Task tool_use or tool_result, + # resolved from ``subagents/agent-.meta.json`` (``toolUseId``) or the + # trunk's ``toolUseResult.agentId``. Distinct from ``agentId``, which is + # *membership* (whose transcript this entry belongs to) — inside an agent + # transcript the two necessarily differ, which is what makes nested + # agent→agent spawns (issue #213) linkable. + # + # A single field suffices because Claude Code streams one content block + # per assistant entry (parallel spawns arrive as separate entries) and + # tool_results anchor 1:1 on their own entries. The degenerate + # several-resultless-spawns-in-one-entry shape — unobserved in real + # transcripts — degrades to the relocation tail-append, never to data + # loss (see ``converter._apply_subagent_meta_links``). + spawnedAgentId: Optional[str] = None class UserTranscriptEntry(BaseTranscriptEntry): @@ -390,6 +405,10 @@ class MessageMeta: None # Skill pairing (see UserTranscriptEntry.sourceToolUseID) ) agent_id: Optional[str] = None + # Sub-agent spawned by this entry's Agent/Task tool call (issue #213); + # see BaseTranscriptEntry.spawnedAgentId for the membership/reference + # distinction. + spawned_agent_id: Optional[str] = None cwd: str = "" git_branch: Optional[str] = None team_name: Optional[str] = None # Active team name (teammates feature) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index dc5ddd4b..dee2d1fa 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2080,9 +2080,13 @@ def _relocate_subagent_blocks( This pass walks the message list, identifies each subagent block by its synthetic ``{trunk}#agent-{agentId}`` sessionId (stamped by ``_integrate_agent_entries``), and re-inserts the block right after - the trunk Task/Agent tool_result whose ``meta.agent_id`` matches. - The block keeps its parentUuid-derived order; only its position in - the linear message list moves. + its spawn anchor — the Task/Agent tool_result whose + ``meta.spawned_agent_id`` (or legacy trunk ``meta.agent_id``) + matches. Anchors may themselves sit inside another agent's block + (nested agents, issue #213): blocks are emitted recursively so each + lands at its spawn position at any depth. The block keeps its + parentUuid-derived order; only its position in the linear message + list moves. Empty subagent session headers (which ``_reorder_session_template_ messages`` leaves at the end) are excluded from blocks and stay @@ -2105,25 +2109,45 @@ def _relocate_subagent_blocks( return messages result: list[TemplateMessage] = [] - for msg in messages: - if id(msg) in block_ids: - continue - result.append(msg) - # An anchor is any trunk-session tool_result that carries an - # ``agent_id`` (set by the loader from - # ``toolUseResult.agentId``). The ``tool_name`` would normally - # be ``"Task"`` or ``"Agent"``, but the tool_factory's - # context-lookup occasionally fails to populate it (e.g. when - # the tool_use sits in a session-fork branch); falling back to - # the agent_id alone keeps relocation working in those cases. + + def _spawned_id(msg: TemplateMessage) -> Optional[str]: + """The agent spawned at this message, if it's a spawn anchor. + + The sidecar-resolved ``spawned_agent_id`` (issue #213) works at any + nesting depth — an anchor INSIDE agent A's block links agent B's + block. The fallback is the legacy trunk shape: a trunk-session + tool_result whose ``agent_id`` is a reference backpatched from + ``toolUseResult.agentId`` (the ``tool_name`` would normally be + ``"Task"`` or ``"Agent"``, but the tool_factory's context-lookup + occasionally fails to populate it — e.g. when the tool_use sits in + a session-fork branch — so the agent_id alone decides). + """ + if msg.meta.spawned_agent_id: + return msg.meta.spawned_agent_id if ( isinstance(msg.content, ToolResultMessage) and msg.meta.agent_id and "#agent-" not in (msg.meta.session_id or "") ): - block = blocks.pop(msg.meta.agent_id, None) + return msg.meta.agent_id + return None + + def _emit(msg: TemplateMessage) -> None: + """Emit a message, then any block it anchors — recursively, so a + nested agent's block lands right after its spawn entry inside the + parent agent's block (one frame per nesting level).""" + result.append(msg) + spawned = _spawned_id(msg) + if spawned: + block = blocks.pop(spawned, None) if block: - result.extend(block) + for member in block: + _emit(member) + + for msg in messages: + if id(msg) in block_ids: + continue + _emit(msg) # Defensive: emit any subagent block whose anchor we never saw, so # content is never silently dropped. @@ -2240,8 +2264,13 @@ def _get_message_hierarchy_level(msg: TemplateMessage) -> int: sidechain assistant (duplicate of Task output) are cleaned up from the tree by _cleanup_sidechain_duplicates after tree building. + The sidechain levels here (4/5) are the DEPTH-1 block: for nested agents + (issue #213) ``_build_message_hierarchy`` shifts a depth-``d`` + transcript's levels by ``2 * (d - 1)`` on top of this function's result. + Returns: - Integer hierarchy level (1-5, session headers are 0) + Integer hierarchy level (1-5, session headers are 0; before the + caller's nesting-depth shift) """ msg_type = msg.type is_sidechain = msg.is_sidechain @@ -2335,9 +2364,48 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: nest under the parent session rather than restart the ancestry. This lets fold controls on the parent session cascade into branch content. + Nested agents (issue #213): every message of a depth-``d`` agent + transcript has its level shifted by ``2 * (d - 1)`` — the size of the + per-transcript level span the depth-1 sidechain rules already use + (assistant 4 / tools 5) — so a sub-sub-agent's entries nest under its + spawning tool pair instead of flattening into the parent agent's + level. Depth comes from chasing the ``spawned_agent_id`` links; agent + sessions without one (legacy data) default to depth 1, reproducing + the pre-#213 behavior exactly. + Args: messages: List of template messages in their final order (modified in place) """ + # Map each agent session line to the session line that spawned it, + # via the sidecar-resolved spawn anchors (see _relocate_subagent_blocks). + parent_sid: dict[str, str] = {} + for message in messages: + spawned = message.meta.spawned_agent_id + if not spawned: + continue + sid = message.meta.session_id or "" + trunk = sid.split("#agent-", 1)[0] + parent_sid[f"{trunk}#agent-{spawned}"] = sid + + depth_cache: dict[str, int] = {} + + def _agent_depth(sid: str) -> int: + """Agent-nesting depth of a session line (trunk 0, direct spawn 1, …). + + Unknown parents (legacy data without spawn links) count as direct + trunk spawns. The provisional cache entry doubles as a cycle + breaker for corrupt linkage.""" + if "#agent-" not in sid: + return 0 + cached = depth_cache.get(sid) + if cached is not None: + return cached + depth_cache[sid] = 1 + parent = parent_sid.get(sid) + depth = 1 + _agent_depth(parent) if parent and parent != sid else 1 + depth_cache[sid] = depth + return depth + # Stack of (level, message_index) tuples. Levels may be fractional for # within-session branch-headers; see class-level note. hierarchy_stack: list[tuple[float, int]] = [] @@ -2351,8 +2419,12 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: elif message.is_session_header: current_level = 0 else: - # Determine level from message type and modifiers + # Determine level from message type and modifiers, shifted by + # the agent-nesting depth of the message's session line. current_level = _get_message_hierarchy_level(message) + agent_depth = _agent_depth(message.meta.session_id or "") + if agent_depth > 1: + current_level += 2 * (agent_depth - 1) # Pop stack until we find the appropriate parent level while hierarchy_stack and hierarchy_stack[-1][0] >= current_level: diff --git a/dev-docs/agents.md b/dev-docs/agents.md index 1f35d8a6..a2ba8e0b 100644 --- a/dev-docs/agents.md +++ b/dev-docs/agents.md @@ -11,6 +11,9 @@ | **Teammates** | `Agent` (or `Task` with `team_name`/`name`); requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` | [teammates.md](teammates.md) (#91) | | **Workflow sub-agents** | `Workflow` tool_use (JS orchestrator fan-out) | [workflows.md](workflows.md) (#174); summary in § 4 | +Any of the Task/Agent flavors may itself spawn sub-agents (Claude Code +2.1.172+) — § 5 covers how the nesting is discovered and rendered. + The first three share the same metadata-tail shape on the spawn's tool_result (`agentId:` line + optional `` block) — `parse_agent_result_metadata` populates `TaskOutput.metadata` for every variant. Workflow sub-agents are @@ -210,3 +213,96 @@ its `workflow_agent` card. See [workflows.md](workflows.md) for the full as-built reference (on-disk layout, parse model, taskId linkage, splice mechanics, detail-level behaviour). + +## 5. Nested agent hierarchies (#213) + +Claude Code 2.1.172+ lets sub-agents spawn their own sub-agents. The +announced "up to 5 levels deep" cap is **not enforced** (a linear chain +was observed at depth 79), so nothing in the pipeline assumes a bound. +(The *practical* bound is Python's ~1000-frame recursion limit — the +loader's child loading, the block relocation and the depth chase all +recurse one frame per level; a pathological multi-hundred-depth chain +would fail as a RecursionError at load.) + +### 5.1 On-disk shape + +The layout stays FLAT at every depth: each agent gets +`/subagents/agent-.jsonl` + `agent-.meta.json` next to its +siblings — nesting never creates subdirectories. Every entry of every +agent file carries the *trunk* `sessionId`, `isSidechain: true` and +`agentId` = the agent's own id; the root entry has `parentUuid: null`. + +The cross-file links differ by direction: + +- **parent → child**: the in-band metadata tail on the spawning + tool_result's content (`agentId: (use SendMessage …)` + + ``). At trunk level the structured `toolUseResult.agentId` + additionally exists — *only* there; nested tool_results carry no + `toolUseResult`. +- **child → parent**: the sidecar `meta.json`'s `toolUseId` names the + spawning tool_use. Crucially this survives spawns that never returned + (an interrupted spawn's tool_result is a generic `is_error` stub with + no tail). + +```mermaid +flowchart LR + subgraph trunk[".jsonl (trunk)"] + TU1["tool_use Agent #1"] --> TR1["tool_result
+ toolUseResult.agentId
+ 'agentId:' tail"] + end + subgraph A["subagents/agent-A.jsonl"] + TU2["tool_use Agent #2"] --> TR2["tool_result
'agentId:' tail ONLY"] + end + subgraph B["subagents/agent-B.jsonl"] + BR["root (parentUuid: null)"] + end + MA["agent-A.meta.json
toolUseId = #1"] -.-> TU1 + MB["agent-B.meta.json
toolUseId = #2"] -.-> TU2 + TR1 ==> A + TR2 ==> B +``` + +### 5.2 Discovery and linking (`spawnedAgentId`) + +`load_transcript` scans the sidecars once per load (memoized per +directory) into `{toolUseId → agentId}` and, for every spawning +tool_use found in the loaded entries, stamps the resolved id on the +spawning tool_result entry (or the tool_use's assistant entry when no +result exists) as the synthetic **`spawnedAgentId`** field — see +`_subagent_meta_map` / `_apply_subagent_meta_links` (converter.py). + +The field exists because `agentId` can't carry both meanings: inside an +agent transcript `agentId` is *membership* (whose transcript the entry +belongs to), while the spawn anchor needs a *reference* (which agent +this entry spawned). The legacy trunk backpatch (copying +`toolUseResult.agentId` into the entry's `agentId`) only worked because +trunk entries have no membership. + +Downstream, `spawnedAgentId` anchors take precedence everywhere the +legacy reference was used: `_integrate_agent_entries` (DAG re-parenting ++ `{trunk}#agent-{id}` stamping — flat synthetic sids stay +collision-free at any depth), the converter's insert-at-point-of-use +pass, and `_relocate_subagent_blocks` (renderer.py), which now emits +blocks recursively so a nested agent's block lands right after its +spawn entry *inside* its parent agent's block. + +### 5.3 Hierarchy levels + +`_get_message_hierarchy_level`'s sidechain levels (assistant 4 / tools +5) are the depth-1 block; `_build_message_hierarchy` shifts a +depth-`d` transcript by `2 * (d - 1)`, chasing depth through the +`spawned_agent_id` links (agent lines without one default to depth 1 — +legacy data renders exactly as before). Each nesting level therefore +adds two DOM levels: the spawn pair, then the child transcript. + +The sidechain dedup (`_cleanup_sidechain_duplicates`) applies per spawn +node at any depth: a transcript whose prompt and final answer both +round-trip verbatim through its spawn pair collapses entirely — a +trivial leaf shows nothing beyond its parent's tool_use/tool_result +pair, exactly like depth-1 sync agents. + +### 5.4 Fixture + +`test/test_data/nested_agents/` (generated by +`scripts/gen_nested_agents_fixture.py`): a 2×2 fan-out, a 3-deep chain, +and an interrupted spawn linkable only via its sidecar. Pinned by +`test/test_nested_agents.py`. diff --git a/dev-docs/dag.md b/dev-docs/dag.md index 3f27a119..81e6b026 100644 --- a/dev-docs/dag.md +++ b/dev-docs/dag.md @@ -132,8 +132,10 @@ Subagent transcripts live in `/subagents/agent-*.jsonl` with `parentUuid: null` on their first entry. Before the DAG is built, `_integrate_agent_entries` (converter.py) makes two adjustments per agent: -1. **Re-parent** the agent's root to the trunk entry whose `agentId` - references this agent (the spawning Task/Agent `tool_result`). Nested +1. **Re-parent** the agent's root to the entry that spawned it — by + preference the sidecar-resolved `spawnedAgentId` anchor (#213; works + at any nesting depth, see [agents.md](agents.md) §5), falling back to + the legacy trunk entry whose `agentId` references this agent. Nested spawns (agent A spawns agent B) anchor inside A's sidechain; a cross-agent-boundary guard prevents an agent's own root from acting as its anchor (which would self-loop). diff --git a/dev-docs/message-hierarchy.md b/dev-docs/message-hierarchy.md index 50cb6906..8275df69 100644 --- a/dev-docs/message-hierarchy.md +++ b/dev-docs/message-hierarchy.md @@ -25,6 +25,10 @@ Session (level 0) **Notes:** - **Paired messages** (tool_use + tool_result, thinking + assistant) fold together as a single visual unit - **Sidechain (sub-agent) messages** appear nested under the Task tool that spawned them +- **Nested agents** (#213): levels 4/5 are the depth-1 block. A sub-agent + spawned *by* a sub-agent repeats the pattern two levels further down + (its sub-assistant at 6, its tools at 7, and so on — depth is + unbounded). See [agents.md](agents.md) §5. - **Deduplication**: When a sub-agent's final message duplicates the Task result, it's replaced with a link to avoid redundancy At each level, we want to fold/unfold immediate children or all children. diff --git a/scripts/gen_nested_agents_fixture.py b/scripts/gen_nested_agents_fixture.py new file mode 100644 index 00000000..3f24148b --- /dev/null +++ b/scripts/gen_nested_agents_fixture.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Generate the synthesized ``nested_agents`` test fixture for issue #213. + +Mirrors the on-disk layout Claude Code 2.1.172+ leaves when sub-agents +spawn their own sub-agents, fully sanitized — no real paths, session ids, +or agent ids. The layout is FLAT at every depth: + + test/test_data/nested_agents/ + .jsonl trunk transcript + /subagents/ + agent-.jsonl (×10) one per agent, ANY nesting depth + agent-.meta.json (×10) {agentType, description, toolUseId} + +Three sub-trees exercise the #213 mechanics: + +- **2×2 fan-out**: trunk spawns mid1 + mid2 in parallel; each mid spawns + two leaves. Leaf answers round-trip verbatim into the spawn tool_result + (so the sidechain dedup collapses them) — EXCEPT leaf22, whose result is + a truncated copy, so its transcript survives dedup and stays visible at + depth 2 (the HTML assertions hang off it). +- **3-deep chain**: trunk → c1 → c2 → c3; each level reports its child's + answer in a distinct wrapper, so every level survives dedup. +- **Interrupted spawn**: the trunk's last spawn was rejected — its + tool_result is the generic is_error stub with NO ``toolUseResult`` and + no ``agentId:`` tail. The transcript + sidecar exist on disk; only the + sidecar's ``toolUseId`` links it (the meta-only path). + +Nested spawn tool_results carry the in-band ``agentId: (use +SendMessage …)`` tail but — faithfully to the real data — NO top-level +``toolUseResult`` (that enrichment is trunk-only). + +Re-run to regenerate: ``python3 scripts/gen_nested_agents_fixture.py``. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / "test" / "test_data" / "nested_agents" + +TRUNK_SID = "33330000-0000-4000-8000-000000000001" +TS = "2026-06-12T09:00:00.000Z" +VERSION = "2.1.173" + +MID1, MID2 = "nsmid001", "nsmid002" +LEAF11, LEAF12, LEAF21, LEAF22 = "nsleaf11", "nsleaf12", "nsleaf21", "nsleaf22" +CHAIN1, CHAIN2, CHAIN3 = "nschain1", "nschain2", "nschain3" +INTR = "nsintr01" + +LEAF_ANSWERS = { + LEAF11: "Log files scroll past — each line a moment captured.", + LEAF12: "6*7 = 42, six added together seven times.", + LEAF21: "Cold caches wait in rows; one warm read and the index hums.", + LEAF22: "9*8 = 72, computed as 9*(10-2) = 90 - 18.", +} +# leaf22's transcript survives the output dedup: the spawn tool_result +# carries a TRUNCATED copy of the answer, so the texts don't match. +LEAF_RESULTS = dict(LEAF_ANSWERS) +LEAF_RESULTS[LEAF22] = "9*8 = 72, computed as 9*(10-…" + +CHAIN3_ANSWER = "depth 3: BOTTOM — stopping here, no further spawn." +CHAIN2_ANSWER = f"depth 2: child said: {CHAIN3_ANSWER}" +CHAIN1_ANSWER = f"depth 1: child said: {CHAIN2_ANSWER}" + + +def _base(uuid: str, parent: str | None, *, agent_id: str | None) -> dict: + e: dict = { + "type": "", # set by caller + "uuid": uuid, + "parentUuid": parent, + "isSidechain": agent_id is not None, + "userType": "external", + "cwd": "/repo", + "sessionId": TRUNK_SID, + "version": VERSION, + "timestamp": TS, + } + if agent_id is not None: + e["agentId"] = agent_id + return e + + +def _user(uuid, parent, content, *, agent_id=None, tool_use_result=None) -> dict: + e = _base(uuid, parent, agent_id=agent_id) + e["type"] = "user" + e["message"] = {"role": "user", "content": content} + if tool_use_result is not None: + e["toolUseResult"] = tool_use_result + return e + + +def _assistant(uuid, parent, content, *, agent_id=None) -> dict: + e = _base(uuid, parent, agent_id=agent_id) + e["type"] = "assistant" + e["message"] = { + "id": f"msg_{uuid}", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5-20251001", + "stop_reason": "end_turn", + "content": content, + "usage": {"input_tokens": 5, "output_tokens": 5}, + } + return e + + +def _spawn_use(tool_use_id: str, description: str, prompt: str) -> dict: + return { + "type": "tool_use", + "id": tool_use_id, + "name": "Agent", + "input": { + "description": description, + "subagent_type": "general-purpose", + "prompt": prompt, + }, + } + + +def _tail(agent_id: str) -> dict: + return { + "type": "text", + "text": ( + f"agentId: {agent_id} (use SendMessage with to: '{agent_id}' " + "to continue this agent)\n" + "subagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500" + ), + } + + +def _spawn_result(tool_use_id: str, agent_id: str, answer: str) -> list[dict]: + return [ + {"type": "text", "text": answer}, + _tail(agent_id), + ] + + +def _tu(agent_id: str) -> str: + """The spawning tool_use id for an agent (one spawn per agent here).""" + return f"toolu_ns_{agent_id}" + + +def _spawner_file( + agent_id: str, + prompt: str, + spawns: list[tuple[str, str, str]], # (child_id, description, child_prompt) + answer: str, +) -> list[dict]: + """An agent transcript that spawns children, then answers. + + Faithful nested shape: every entry carries the TRUNK sessionId, + isSidechain=true and the agent's own id; spawn tool_results have the + in-band tail but no toolUseResult. + """ + p = f"{agent_id}-" + rows = [_user(p + "u1", None, prompt, agent_id=agent_id)] + uses = [_spawn_use(_tu(c), d, cp) for c, d, cp in spawns] + rows.append(_assistant(p + "a1", p + "u1", uses, agent_id=agent_id)) + parent = p + "a1" + for i, (child_id, _d, _p) in enumerate(spawns, 1): + child_answer = ( + LEAF_RESULTS.get(child_id) + or {CHAIN2: CHAIN2_ANSWER, CHAIN3: CHAIN3_ANSWER}[child_id] + ) + rows.append( + _user( + f"{p}r{i}", + parent, + [ + { + "type": "tool_result", + "tool_use_id": _tu(child_id), + "content": _spawn_result(_tu(child_id), child_id, child_answer), + } + ], + agent_id=agent_id, + ) + ) + parent = f"{p}r{i}" + rows.append( + _assistant( + p + "a2", parent, [{"type": "text", "text": answer}], agent_id=agent_id + ) + ) + return rows + + +def _leaf_file(agent_id: str, prompt: str) -> list[dict]: + p = f"{agent_id}-" + return [ + _user(p + "u1", None, prompt, agent_id=agent_id), + _assistant( + p + "a1", + p + "u1", + [{"type": "text", "text": LEAF_ANSWERS[agent_id]}], + agent_id=agent_id, + ), + ] + + +def _trunk() -> list[dict]: + mid_prompt = "Spawn two leaves and report both answers." + return [ + _user( + "ns-u1", + None, + [{"type": "text", "text": "Demonstrate nested agents (2x2 + chain)."}], + ), + _assistant( + "ns-a1", + "ns-u1", + [ + {"type": "text", "text": "Spawning two mid-agents in parallel."}, + _spawn_use(_tu(MID1), "Mid-agent 1", mid_prompt), + _spawn_use(_tu(MID2), "Mid-agent 2", mid_prompt), + ], + ), + _user( + "ns-r1", + "ns-a1", + [ + { + "type": "tool_result", + "tool_use_id": _tu(MID1), + "content": _spawn_result( + _tu(MID1), + MID1, + f"L11 said: {LEAF_ANSWERS[LEAF11]}\nL12 said: {LEAF_ANSWERS[LEAF12]}", + ), + } + ], + # Trunk-level spawns additionally get the structured enrichment. + tool_use_result={"agentId": MID1, "status": "completed"}, + ), + _user( + "ns-r2", + "ns-r1", + [ + { + "type": "tool_result", + "tool_use_id": _tu(MID2), + "content": _spawn_result( + _tu(MID2), + MID2, + f"L21 said: {LEAF_ANSWERS[LEAF21]}\nL22 said: {LEAF_ANSWERS[LEAF22]}", + ), + } + ], + tool_use_result={"agentId": MID2, "status": "completed"}, + ), + _assistant( + "ns-a2", + "ns-r2", + [ + {"type": "text", "text": "Now the recursion chain."}, + _spawn_use(_tu(CHAIN1), "Chain depth 1", "Recurse to depth 3."), + ], + ), + _user( + "ns-r3", + "ns-a2", + [ + { + "type": "tool_result", + "tool_use_id": _tu(CHAIN1), + "content": _spawn_result(_tu(CHAIN1), CHAIN1, CHAIN1_ANSWER), + } + ], + tool_use_result={"agentId": CHAIN1, "status": "completed"}, + ), + _assistant( + "ns-a3", + "ns-r3", + [ + {"type": "text", "text": "One more spawn (will be interrupted)."}, + _spawn_use(_tu(INTR), "Doomed spawn", "Run forever."), + ], + ), + # Interrupted spawn: generic rejection, is_error, NO toolUseResult, + # no agentId tail — the sidecar's toolUseId is the only link. + _user( + "ns-r4", + "ns-a3", + [ + { + "type": "tool_result", + "tool_use_id": _tu(INTR), + "is_error": True, + "content": ( + "The user doesn't want to proceed with this tool use. " + "The tool use was rejected." + ), + } + ], + ), + _assistant( + "ns-a4", + "ns-r4", + [{"type": "text", "text": "All scenarios done."}], + ), + ] + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8") + + +def main() -> None: + # Regenerate from scratch — stale files from a previous layout (renamed + # agent ids, removed scenarios) must not survive in the fixture. + shutil.rmtree(FIXTURE, ignore_errors=True) + subagents = FIXTURE / TRUNK_SID / "subagents" + + _write_jsonl(FIXTURE / f"{TRUNK_SID}.jsonl", _trunk()) + + leaf_prompt = "Answer your one-line task. Use no tools." + files: dict[str, list[dict]] = { + MID1: _spawner_file( + MID1, + "Spawn two leaves and report both answers.", + [(LEAF11, "Leaf 1.1", leaf_prompt), (LEAF12, "Leaf 1.2", leaf_prompt)], + f"L11 said: {LEAF_ANSWERS[LEAF11]}\nL12 said: {LEAF_ANSWERS[LEAF12]}", + ), + MID2: _spawner_file( + MID2, + "Spawn two leaves and report both answers.", + [(LEAF21, "Leaf 2.1", leaf_prompt), (LEAF22, "Leaf 2.2", leaf_prompt)], + f"L21 said: {LEAF_ANSWERS[LEAF21]}\nL22 said: {LEAF_ANSWERS[LEAF22]}", + ), + LEAF11: _leaf_file(LEAF11, leaf_prompt), + LEAF12: _leaf_file(LEAF12, leaf_prompt), + LEAF21: _leaf_file(LEAF21, leaf_prompt), + LEAF22: _leaf_file(LEAF22, leaf_prompt), + CHAIN1: _spawner_file( + CHAIN1, + "You are at depth 1. Recurse.", + [(CHAIN2, "Chain depth 2", "You are at depth 2. Recurse.")], + CHAIN1_ANSWER, + ), + CHAIN2: _spawner_file( + CHAIN2, + "You are at depth 2. Recurse.", + [(CHAIN3, "Chain depth 3", "You are at depth 3. Stop.")], + CHAIN2_ANSWER, + ), + CHAIN3: [ + _user(f"{CHAIN3}-u1", None, "You are at depth 3. Stop.", agent_id=CHAIN3), + _assistant( + f"{CHAIN3}-a1", + f"{CHAIN3}-u1", + [{"type": "text", "text": CHAIN3_ANSWER}], + agent_id=CHAIN3, + ), + ], + # The interrupted agent got to think before being killed; there is + # no final answer and no result tail anywhere. + INTR: [ + _user(f"{INTR}-u1", None, "Run forever.", agent_id=INTR), + _assistant( + f"{INTR}-a1", + f"{INTR}-u1", + [{"type": "thinking", "thinking": "Looping…"}], + agent_id=INTR, + ), + ], + } + + descriptions = { + MID1: "Mid-agent 1", + MID2: "Mid-agent 2", + LEAF11: "Leaf 1.1", + LEAF12: "Leaf 1.2", + LEAF21: "Leaf 2.1", + LEAF22: "Leaf 2.2", + CHAIN1: "Chain depth 1", + CHAIN2: "Chain depth 2", + CHAIN3: "Chain depth 3", + INTR: "Doomed spawn", + } + for agent_id, rows in files.items(): + _write_jsonl(subagents / f"agent-{agent_id}.jsonl", rows) + (subagents / f"agent-{agent_id}.meta.json").write_text( + json.dumps( + { + "agentType": "general-purpose", + "description": descriptions[agent_id], + "toolUseId": _tu(agent_id), + } + ) + + "\n", + encoding="utf-8", + ) + + n_files = sum(1 for _ in FIXTURE.rglob("*") if _.is_file()) + print(f"Wrote {n_files} files under {FIXTURE}") + + +if __name__ == "__main__": + main() diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 982e6b2c..38680b1d 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1889,16 +1889,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -1927,7 +1935,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -8198,16 +8207,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -8236,7 +8253,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -16594,16 +16612,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -16632,7 +16658,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -23018,16 +23045,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -23056,7 +23091,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -29709,16 +29745,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -29747,7 +29791,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -36363,16 +36408,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -36401,7 +36454,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } @@ -42960,16 +43014,24 @@ border-left: 0px solid var(--tool-use-color); } - /* A standard sub-agent's sidechain group (Task-spawned agents — sync, - async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE line in tool-green — per the color-pairing + /* A sub-agent's sidechain group (Task/Agent-spawned — sync, async, + teammates): the agent's transcript hangs under its spawning tool card, + indented and framed by ONE line in tool-green — per the color-pairing principle, the group line CONTINUES its parent card's border, and the - spawning tool_result card's border is tool-green. (Workflow agents use - the same principle with their own grey: grey workflow_agent card → - grey side-channel line.) The :not(.sidechain) parent filter keeps the - line at the block's top level only (containers deeper inside the - sidechain hang under .sidechain-flagged cards). */ - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + spawning tool card's border is tool-green. (Workflow agents use the + same principle with their own grey: grey workflow_agent card → grey + side-channel line.) The rule matches at EVERY nesting depth (#213): + a nested agent's group hangs under a spawn card that is itself + .sidechain, and each spawn boundary adds its own 2em step + line, so + depth accumulates through the DOM with no per-depth rules. Both + tool_use and tool_result parents are covered — a transcript attaches + to the tool_use when the spawn has no result yet (running / + interrupted). The :not(.workflow_agent) keeps this higher-specificity + rule off workflow agents' side-channel groups (their card is ALSO + tool_use-classed; their group line is grey, see below). Per-depth + line colors + depth badges: PR2 of #213. */ + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; border-left: 2px solid var(--tool-use-color); } @@ -42998,7 +43060,8 @@ .children:has(> .message-node > .message.workflow_phase), .message-node:has(> .message.workflow_phase) > .children, .message-node:has(> .message.workflow_agent) > .children, - .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { + .message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain), + .message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2%; } } diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl new file mode 100644 index 00000000..ff5cd37c --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl @@ -0,0 +1,9 @@ +{"type": "user", "uuid": "ns-u1", "parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "Demonstrate nested agents (2x2 + chain)."}]}} +{"type": "assistant", "uuid": "ns-a1", "parentUuid": "ns-u1", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"id": "msg_ns-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Spawning two mid-agents in parallel."}, {"type": "tool_use", "id": "toolu_ns_nsmid001", "name": "Agent", "input": {"description": "Mid-agent 1", "subagent_type": "general-purpose", "prompt": "Spawn two leaves and report both answers."}}, {"type": "tool_use", "id": "toolu_ns_nsmid002", "name": "Agent", "input": {"description": "Mid-agent 2", "subagent_type": "general-purpose", "prompt": "Spawn two leaves and report both answers."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "ns-r1", "parentUuid": "ns-a1", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsmid001", "content": [{"type": "text", "text": "L11 said: Log files scroll past \u2014 each line a moment captured.\nL12 said: 6*7 = 42, six added together seven times."}, {"type": "text", "text": "agentId: nsmid001 (use SendMessage with to: 'nsmid001' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}, "toolUseResult": {"agentId": "nsmid001", "status": "completed"}} +{"type": "user", "uuid": "ns-r2", "parentUuid": "ns-r1", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsmid002", "content": [{"type": "text", "text": "L21 said: Cold caches wait in rows; one warm read and the index hums.\nL22 said: 9*8 = 72, computed as 9*(10-2) = 90 - 18."}, {"type": "text", "text": "agentId: nsmid002 (use SendMessage with to: 'nsmid002' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}, "toolUseResult": {"agentId": "nsmid002", "status": "completed"}} +{"type": "assistant", "uuid": "ns-a2", "parentUuid": "ns-r2", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"id": "msg_ns-a2", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Now the recursion chain."}, {"type": "tool_use", "id": "toolu_ns_nschain1", "name": "Agent", "input": {"description": "Chain depth 1", "subagent_type": "general-purpose", "prompt": "Recurse to depth 3."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "ns-r3", "parentUuid": "ns-a2", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nschain1", "content": [{"type": "text", "text": "depth 1: child said: depth 2: child said: depth 3: BOTTOM \u2014 stopping here, no further spawn."}, {"type": "text", "text": "agentId: nschain1 (use SendMessage with to: 'nschain1' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}, "toolUseResult": {"agentId": "nschain1", "status": "completed"}} +{"type": "assistant", "uuid": "ns-a3", "parentUuid": "ns-r3", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"id": "msg_ns-a3", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "One more spawn (will be interrupted)."}, {"type": "tool_use", "id": "toolu_ns_nsintr01", "name": "Agent", "input": {"description": "Doomed spawn", "subagent_type": "general-purpose", "prompt": "Run forever."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "ns-r4", "parentUuid": "ns-a3", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsintr01", "is_error": true, "content": "The user doesn't want to proceed with this tool use. The tool use was rejected."}]}} +{"type": "assistant", "uuid": "ns-a4", "parentUuid": "ns-r4", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "message": {"id": "msg_ns-a4", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "All scenarios done."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonl new file mode 100644 index 00000000..9e3646c2 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonl @@ -0,0 +1,4 @@ +{"type": "user", "uuid": "nschain1-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain1", "message": {"role": "user", "content": "You are at depth 1. Recurse."}} +{"type": "assistant", "uuid": "nschain1-a1", "parentUuid": "nschain1-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain1", "message": {"id": "msg_nschain1-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "tool_use", "id": "toolu_ns_nschain2", "name": "Agent", "input": {"description": "Chain depth 2", "subagent_type": "general-purpose", "prompt": "You are at depth 2. Recurse."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "nschain1-r1", "parentUuid": "nschain1-a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain1", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nschain2", "content": [{"type": "text", "text": "depth 2: child said: depth 3: BOTTOM \u2014 stopping here, no further spawn."}, {"type": "text", "text": "agentId: nschain2 (use SendMessage with to: 'nschain2' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "assistant", "uuid": "nschain1-a2", "parentUuid": "nschain1-r1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain1", "message": {"id": "msg_nschain1-a2", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "depth 1: child said: depth 2: child said: depth 3: BOTTOM \u2014 stopping here, no further spawn."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.json new file mode 100644 index 00000000..6c8e61f5 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Chain depth 1", "toolUseId": "toolu_ns_nschain1"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonl new file mode 100644 index 00000000..aa216c85 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonl @@ -0,0 +1,4 @@ +{"type": "user", "uuid": "nschain2-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain2", "message": {"role": "user", "content": "You are at depth 2. Recurse."}} +{"type": "assistant", "uuid": "nschain2-a1", "parentUuid": "nschain2-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain2", "message": {"id": "msg_nschain2-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "tool_use", "id": "toolu_ns_nschain3", "name": "Agent", "input": {"description": "Chain depth 3", "subagent_type": "general-purpose", "prompt": "You are at depth 3. Stop."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "nschain2-r1", "parentUuid": "nschain2-a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nschain3", "content": [{"type": "text", "text": "depth 3: BOTTOM \u2014 stopping here, no further spawn."}, {"type": "text", "text": "agentId: nschain3 (use SendMessage with to: 'nschain3' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "assistant", "uuid": "nschain2-a2", "parentUuid": "nschain2-r1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain2", "message": {"id": "msg_nschain2-a2", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "depth 2: child said: depth 3: BOTTOM \u2014 stopping here, no further spawn."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.json new file mode 100644 index 00000000..3bf94b1a --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Chain depth 2", "toolUseId": "toolu_ns_nschain2"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonl new file mode 100644 index 00000000..9e54f4fc --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nschain3-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain3", "message": {"role": "user", "content": "You are at depth 3. Stop."}} +{"type": "assistant", "uuid": "nschain3-a1", "parentUuid": "nschain3-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nschain3", "message": {"id": "msg_nschain3-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "depth 3: BOTTOM \u2014 stopping here, no further spawn."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.json new file mode 100644 index 00000000..a1b0cc4f --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Chain depth 3", "toolUseId": "toolu_ns_nschain3"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonl new file mode 100644 index 00000000..08703f85 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nsintr01-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsintr01", "message": {"role": "user", "content": "Run forever."}} +{"type": "assistant", "uuid": "nsintr01-a1", "parentUuid": "nsintr01-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsintr01", "message": {"id": "msg_nsintr01-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "thinking", "thinking": "Looping\u2026"}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.json new file mode 100644 index 00000000..3fa8a20f --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Doomed spawn", "toolUseId": "toolu_ns_nsintr01"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonl new file mode 100644 index 00000000..ffcaf2b1 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nsleaf11-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf11", "message": {"role": "user", "content": "Answer your one-line task. Use no tools."}} +{"type": "assistant", "uuid": "nsleaf11-a1", "parentUuid": "nsleaf11-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf11", "message": {"id": "msg_nsleaf11-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Log files scroll past \u2014 each line a moment captured."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.json new file mode 100644 index 00000000..abed3476 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Leaf 1.1", "toolUseId": "toolu_ns_nsleaf11"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonl new file mode 100644 index 00000000..a56b06c9 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nsleaf12-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf12", "message": {"role": "user", "content": "Answer your one-line task. Use no tools."}} +{"type": "assistant", "uuid": "nsleaf12-a1", "parentUuid": "nsleaf12-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf12", "message": {"id": "msg_nsleaf12-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "6*7 = 42, six added together seven times."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.json new file mode 100644 index 00000000..163e117d --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Leaf 1.2", "toolUseId": "toolu_ns_nsleaf12"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonl new file mode 100644 index 00000000..4499ab5c --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nsleaf21-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf21", "message": {"role": "user", "content": "Answer your one-line task. Use no tools."}} +{"type": "assistant", "uuid": "nsleaf21-a1", "parentUuid": "nsleaf21-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf21", "message": {"id": "msg_nsleaf21-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Cold caches wait in rows; one warm read and the index hums."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.json new file mode 100644 index 00000000..b3c61040 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Leaf 2.1", "toolUseId": "toolu_ns_nsleaf21"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonl new file mode 100644 index 00000000..9da63db8 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonl @@ -0,0 +1,2 @@ +{"type": "user", "uuid": "nsleaf22-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf22", "message": {"role": "user", "content": "Answer your one-line task. Use no tools."}} +{"type": "assistant", "uuid": "nsleaf22-a1", "parentUuid": "nsleaf22-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsleaf22", "message": {"id": "msg_nsleaf22-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "9*8 = 72, computed as 9*(10-2) = 90 - 18."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.json new file mode 100644 index 00000000..57a2a025 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Leaf 2.2", "toolUseId": "toolu_ns_nsleaf22"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonl new file mode 100644 index 00000000..5db702e7 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonl @@ -0,0 +1,5 @@ +{"type": "user", "uuid": "nsmid001-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid001", "message": {"role": "user", "content": "Spawn two leaves and report both answers."}} +{"type": "assistant", "uuid": "nsmid001-a1", "parentUuid": "nsmid001-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid001", "message": {"id": "msg_nsmid001-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "tool_use", "id": "toolu_ns_nsleaf11", "name": "Agent", "input": {"description": "Leaf 1.1", "subagent_type": "general-purpose", "prompt": "Answer your one-line task. Use no tools."}}, {"type": "tool_use", "id": "toolu_ns_nsleaf12", "name": "Agent", "input": {"description": "Leaf 1.2", "subagent_type": "general-purpose", "prompt": "Answer your one-line task. Use no tools."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "nsmid001-r1", "parentUuid": "nsmid001-a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid001", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsleaf11", "content": [{"type": "text", "text": "Log files scroll past \u2014 each line a moment captured."}, {"type": "text", "text": "agentId: nsleaf11 (use SendMessage with to: 'nsleaf11' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "user", "uuid": "nsmid001-r2", "parentUuid": "nsmid001-r1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid001", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsleaf12", "content": [{"type": "text", "text": "6*7 = 42, six added together seven times."}, {"type": "text", "text": "agentId: nsleaf12 (use SendMessage with to: 'nsleaf12' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "assistant", "uuid": "nsmid001-a2", "parentUuid": "nsmid001-r2", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid001", "message": {"id": "msg_nsmid001-a2", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "L11 said: Log files scroll past \u2014 each line a moment captured.\nL12 said: 6*7 = 42, six added together seven times."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.json new file mode 100644 index 00000000..f75641a8 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Mid-agent 1", "toolUseId": "toolu_ns_nsmid001"} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonl b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonl new file mode 100644 index 00000000..0e997e45 --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonl @@ -0,0 +1,5 @@ +{"type": "user", "uuid": "nsmid002-u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid002", "message": {"role": "user", "content": "Spawn two leaves and report both answers."}} +{"type": "assistant", "uuid": "nsmid002-a1", "parentUuid": "nsmid002-u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid002", "message": {"id": "msg_nsmid002-a1", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "tool_use", "id": "toolu_ns_nsleaf21", "name": "Agent", "input": {"description": "Leaf 2.1", "subagent_type": "general-purpose", "prompt": "Answer your one-line task. Use no tools."}}, {"type": "tool_use", "id": "toolu_ns_nsleaf22", "name": "Agent", "input": {"description": "Leaf 2.2", "subagent_type": "general-purpose", "prompt": "Answer your one-line task. Use no tools."}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} +{"type": "user", "uuid": "nsmid002-r1", "parentUuid": "nsmid002-a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid002", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsleaf21", "content": [{"type": "text", "text": "Cold caches wait in rows; one warm read and the index hums."}, {"type": "text", "text": "agentId: nsleaf21 (use SendMessage with to: 'nsleaf21' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "user", "uuid": "nsmid002-r2", "parentUuid": "nsmid002-r1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid002", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_ns_nsleaf22", "content": [{"type": "text", "text": "9*8 = 72, computed as 9*(10-\u2026"}, {"type": "text", "text": "agentId: nsleaf22 (use SendMessage with to: 'nsleaf22' to continue this agent)\nsubagent_tokens: 999\ntool_uses: 0\nduration_ms: 1500"}]}]}} +{"type": "assistant", "uuid": "nsmid002-a2", "parentUuid": "nsmid002-r2", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "33330000-0000-4000-8000-000000000001", "version": "2.1.173", "timestamp": "2026-06-12T09:00:00.000Z", "agentId": "nsmid002", "message": {"id": "msg_nsmid002-a2", "type": "message", "role": "assistant", "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "content": [{"type": "text", "text": "L21 said: Cold caches wait in rows; one warm read and the index hums.\nL22 said: 9*8 = 72, computed as 9*(10-2) = 90 - 18."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.json b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.json new file mode 100644 index 00000000..d084d67b --- /dev/null +++ b/test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Mid-agent 2", "toolUseId": "toolu_ns_nsmid002"} diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py new file mode 100644 index 00000000..509a9173 --- /dev/null +++ b/test/test_nested_agents.py @@ -0,0 +1,350 @@ +"""Nested sub-agent hierarchies (issue #213). + +Claude Code 2.1.172+ lets sub-agents spawn their own sub-agents. All +transcripts land FLAT in the trunk session's ``subagents/`` dir; the only +depth-proof links are the in-band ``agentId:`` result tail and the sidecar +``agent-.meta.json``'s ``toolUseId`` (which also covers interrupted +spawns that never produced a usable tool_result). These tests pin the +loader's sidecar-driven discovery (``spawnedAgentId``), the DAG parentage, +the depth-shifted hierarchy levels, and the recursive block relocation — +each agent's transcript must nest under its own spawn pair at any depth. + +Fixture: ``test/test_data/nested_agents/`` (see +``scripts/gen_nested_agents_fixture.py``) — a 2×2 fan-out, a 3-deep chain, +and one interrupted spawn with a meta-only link. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from claude_code_log.converter import ( + _integrate_agent_entries, + load_directory_transcripts, + load_transcript, +) +from claude_code_log.dag import build_dag_from_entries +from claude_code_log.models import BaseTranscriptEntry, TranscriptEntry +from claude_code_log.renderer import TemplateMessage, generate_template_messages + +TRUNK_SID = "33330000-0000-4000-8000-000000000001" +TRUNK = Path(__file__).parent / "test_data" / "nested_agents" / f"{TRUNK_SID}.jsonl" + +MID1, MID2 = "nsmid001", "nsmid002" +LEAVES = ["nsleaf11", "nsleaf12", "nsleaf21", "nsleaf22"] +CHAIN1, CHAIN2, CHAIN3 = "nschain1", "nschain2", "nschain3" +INTR = "nsintr01" +ALL_AGENTS = [MID1, MID2, *LEAVES, CHAIN1, CHAIN2, CHAIN3, INTR] + +# Spawner → spawned (the ground truth the linkage must reproduce). +SPAWNED_BY = { + MID1: None, # trunk + MID2: None, + CHAIN1: None, + INTR: None, + "nsleaf11": MID1, + "nsleaf12": MID1, + "nsleaf21": MID2, + "nsleaf22": MID2, + CHAIN2: CHAIN1, + CHAIN3: CHAIN2, +} + + +def _load_integrated() -> list[TranscriptEntry]: + entries = load_transcript(TRUNK, silent=True) + _integrate_agent_entries(entries) + return entries + + +def _line_of(sid: str) -> Optional[str]: + return sid.rsplit("#agent-", 1)[-1] if "#agent-" in sid else None + + +def _base_entries(entries: list[TranscriptEntry]) -> list[BaseTranscriptEntry]: + """The entries carrying DAG/agent fields (drops Summary & friends).""" + return [e for e in entries if isinstance(e, BaseTranscriptEntry)] + + +def _members(entries: list[TranscriptEntry]) -> set[str]: + """Membership ids — whose transcripts actually loaded. (The trunk's + own spawn anchors carry the legacy agentId backpatch, so restrict to + sidechain entries.)""" + return {e.agentId for e in _base_entries(entries) if e.isSidechain and e.agentId} + + +class TestNestedDiscovery: + def test_all_transcripts_load_at_every_depth(self) -> None: + entries = load_transcript(TRUNK, silent=True) + assert _members(entries) == set(ALL_AGENTS) + + def test_spawn_links_resolved_from_sidecars(self) -> None: + entries = load_transcript(TRUNK, silent=True) + links: dict[str, Optional[str]] = {} + for e in _base_entries(entries): + if e.spawnedAgentId: + # The spawning entry's membership is the spawner. + links[e.spawnedAgentId] = e.agentId if e.isSidechain else None + # Trunk anchors got the legacy agentId backpatch too — their + # membership reads as the spawned id itself; normalize. + for child, parent in SPAWNED_BY.items(): + if parent is None: + assert child in links, f"{child} not linked" + assert links[child] in (None, child) + else: + assert links.get(child) == parent + + def test_interrupted_spawn_links_via_meta_only(self) -> None: + # The rejected spawn's tool_result has no tail and no toolUseResult: + # the sidecar's toolUseId must still link the transcript. + entries = load_transcript(TRUNK, silent=True) + intr_entries = [ + e for e in _base_entries(entries) if e.isSidechain and e.agentId == INTR + ] + assert intr_entries, "interrupted agent's transcript must load" + anchors = [e for e in _base_entries(entries) if e.spawnedAgentId == INTR] + assert len(anchors) == 1 + assert not anchors[0].isSidechain + + def test_directory_load_matches_single_file(self) -> None: + msgs, _tree = load_directory_transcripts(TRUNK.parent, silent=True) + assert _members(msgs) == set(ALL_AGENTS) + + +class TestNestedDag: + def test_depth_histogram(self) -> None: + tree = build_dag_from_entries(_load_integrated()) + + def depth(sid: str, seen: tuple[str, ...] = ()) -> int: + line = tree.sessions.get(sid) + if line is None or line.parent_session_id is None or sid in seen: + return 0 + return 1 + depth(line.parent_session_id, seen + (sid,)) + + histogram: dict[int, int] = {} + for sid in tree.sessions: + histogram[depth(sid)] = histogram.get(depth(sid), 0) + 1 + assert histogram == {0: 1, 1: 4, 2: 5, 3: 1} + + def test_no_unparented_sidechain_roots(self) -> None: + orphans = [ + e + for e in _base_entries(_load_integrated()) + if e.isSidechain and e.parentUuid is None + ] + assert orphans == [] + + +class TestNestedTree: + def _tree(self) -> tuple[list[TemplateMessage], dict[str, list[str]]]: + """Build the template tree + a map of agent line → ancestor lines + (the agent-line sequence above each agent's topmost tree nodes).""" + roots, _nav, ctx = generate_template_messages(_load_integrated()) + by_index = {m.message_index: m for m in ctx.messages if m is not None} + lines_above: dict[str, list[str]] = {} + + def visit(node: TemplateMessage) -> None: + line = _line_of(node.meta.session_id or "") + if line and line not in lines_above: + # Ancestors of the line's OWN transcript don't count (the + # ancestry may retain its later-deduped prompt entry); the + # chain is the SPAWNER path above the transcript. + chain: list[str] = [] + for idx in node.ancestry: + anc = by_index.get(idx) + if anc is None: + continue + anc_line = _line_of(anc.meta.session_id or "") + if ( + anc_line + and anc_line != line + and (not chain or chain[-1] != anc_line) + ): + chain.append(anc_line) + lines_above[line] = chain + for child in node.children: + visit(child) + + for root in roots: + visit(root) + return roots, lines_above + + def test_each_agent_nests_under_its_spawner(self) -> None: + # Transcripts whose every entry duplicates its spawn pair (verbatim + # leaves, the chain bottom) collapse entirely — exactly like the + # depth-1 dedup; the others must hang under their true spawner. + _roots, lines_above = self._tree() + assert set(lines_above) == {MID1, MID2, "nsleaf22", CHAIN1, CHAIN2, INTR} + for child, chain in lines_above.items(): + parent = SPAWNED_BY[child] + if parent is None: + assert chain == [], f"{child} must hang off the trunk, got {chain}" + else: + assert chain and chain[-1] == parent, ( + f"{child} must nest inside {parent}, got {chain}" + ) + + def test_chain_ancestry_is_the_full_path(self) -> None: + # chain2's spawn pair (the deepest surviving chain nodes) sits + # inside chain1's transcript; chain3's own answer collapses into + # chain2's tool_result content (verbatim duplicate). + _roots, lines_above = self._tree() + assert lines_above.get(CHAIN2) == [CHAIN1] + assert CHAIN3 not in lines_above + + def test_divergent_leaf_survives_dedup_at_depth_2(self) -> None: + # leaf22's answer differs from the spawn result (truncated copy), so + # its transcript stays visible — nested inside mid2. + _roots, lines_above = self._tree() + assert lines_above.get("nsleaf22") == [MID2] + # Its verbatim siblings collapse entirely (prompt + answer are + # duplicates of the spawn pair) — same as depth-1 behavior. + for collapsed in ("nsleaf11", "nsleaf12", "nsleaf21"): + assert collapsed not in lines_above + + def test_interrupted_transcript_nests_under_error_result(self) -> None: + _roots, lines_above = self._tree() + assert lines_above.get(INTR) == [] + + +class TestNestedCacheInvalidation: + def test_new_sidecar_invalidates_cached_trunk(self, tmp_path: Path) -> None: + """Sidecar inputs are part of the cache key (PR #218 review). + + The trunk jsonl's mtime alone can't see a sidecar that appears + AFTER the transcript was cached (nested spawns never touch the + trunk file): without the subagents fingerprint the cached — + agent-less — parse would be served forever.""" + import shutil + + from claude_code_log.cache import CacheManager + + proj = tmp_path / "proj" + proj.mkdir() + trunk = proj / TRUNK.name + shutil.copy(TRUNK, trunk) + + cm = CacheManager(proj, "0.0.0-test", db_path=tmp_path / "cache.db") + first = load_transcript(trunk, cache_manager=cm, silent=True) + assert _members(first) == set(), "no agent transcripts on disk yet" + # Baseline cache hit while the world is unchanged. + assert cm.is_file_cached(trunk) + + # The agents finish: transcripts + sidecars appear, trunk untouched. + shutil.copytree(TRUNK.parent / TRUNK_SID, proj / TRUNK_SID) + + assert not cm.is_file_cached(trunk), ( + "new sidecars must invalidate the cached parse" + ) + rediscovered = load_transcript(trunk, cache_manager=cm, silent=True) + assert _members(rediscovered) == set(ALL_AGENTS) + # And the refreshed cache entry is valid again. + assert cm.is_file_cached(trunk) + + def test_sidecar_landing_mid_parse_invalidates_next_read( + self, tmp_path: Path + ) -> None: + """TOCTOU window (delta-review advisory): the stored fingerprint + must describe the world AS OF THE PARSE — a sidecar landing + between the parse's sidecar scan and the save must mismatch on + the next read (over-invalidation), not be fingerprinted as + covered by a parse that never saw it.""" + import shutil + + from claude_code_log.cache import CacheManager, subagents_fingerprint + + proj = tmp_path / "proj" + proj.mkdir() + trunk = proj / TRUNK.name + shutil.copy(TRUNK, trunk) + + cm = CacheManager(proj, "0.0.0-test", db_path=tmp_path / "cache.db") + # The parse captured its fingerprint… + fp_at_parse = subagents_fingerprint(trunk) + entries = load_transcript(trunk, silent=True) + # …then a sidecar landed before the save. + shutil.copytree(TRUNK.parent / TRUNK_SID, proj / TRUNK_SID) + cm.save_cached_entries(trunk, entries, subagents_fp=fp_at_parse) + + assert not cm.is_file_cached(trunk), ( + "a parse-time fingerprint must not validate the late sidecar" + ) + + +class TestMultiSpawnGuard: + def test_resultless_parallel_spawns_in_one_entry_degrade_safely(self) -> None: + """Degenerate shape (unobserved in real transcripts — Claude Code + streams one content block per assistant entry): a single entry + with TWO resultless spawn tool_uses. The single spawnedAgentId + keeps the first link, never silently overwrites, and both + transcripts still join the loading set.""" + from claude_code_log.converter import _apply_subagent_meta_links + from claude_code_log.factories import create_transcript_entry + + entry = create_transcript_entry( + { + "type": "assistant", + "uuid": "ms-a1", + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/repo", + "sessionId": "ms-trunk", + "version": "2.1.173", + "timestamp": "2026-06-12T09:00:00.000Z", + "message": { + "id": "msg_ms-a1", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5-20251001", + "content": [ + { + "type": "tool_use", + "id": "toolu_ms_1", + "name": "Agent", + "input": {"prompt": "one"}, + }, + { + "type": "tool_use", + "id": "toolu_ms_2", + "name": "Agent", + "input": {"prompt": "two"}, + }, + ], + }, + } + ) + assert isinstance(entry, BaseTranscriptEntry) + agent_ids: set[str] = set() + _apply_subagent_meta_links( + [entry], + {"toolu_ms_1": "agms0001", "toolu_ms_2": "agms0002"}, + agent_ids, + Path("ms-trunk.jsonl"), + ) + assert agent_ids == {"agms0001", "agms0002"}, "both transcripts load" + # Deterministic first link kept (sorted iteration), second skipped. + assert entry.spawnedAgentId == "agms0001" + + +class TestNestedRendering: + def test_html_renders_nested_content(self) -> None: + from claude_code_log.html.renderer import generate_html + + html = generate_html(_load_integrated(), "Nested Agents Test") + # The chain bottom's answer surfaces in chain2's tool_result. + assert "depth 3: BOTTOM" in html + # The surviving depth-2 leaf's full (untruncated) answer — matched + # without the asterisks, which Markdown turns into markup. + assert "(10-2) = 90 - 18" in html + # The interrupted agent's transcript renders despite the rejected + # tool_result. + assert "Looping…" in html + + def test_markdown_renders_nested_content(self) -> None: + from claude_code_log.markdown.renderer import MarkdownRenderer + + md = MarkdownRenderer().generate(_load_integrated()) + assert "depth 3: BOTTOM" in md + assert "(10-2) = 90 - 18" in md diff --git a/test/test_nested_agents_browser.py b/test/test_nested_agents_browser.py new file mode 100644 index 00000000..726e05e1 --- /dev/null +++ b/test/test_nested_agents_browser.py @@ -0,0 +1,67 @@ +"""Runtime CSS contract for nested sub-agent groups (#213). + +Every spawn boundary — at ANY depth — indents its transcript group by +2em and frames it with the tool-green line; depth accumulates through +DOM nesting (a depth-2 group lives inside a depth-1 group). Computed +styles are read directly so default fold state doesn't matter. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from playwright.sync_api import Page + +from claude_code_log.converter import _integrate_agent_entries, load_transcript +from claude_code_log.html.renderer import generate_html + +TRUNK_SID = "33330000-0000-4000-8000-000000000001" +TRUNK = Path(__file__).parent / "test_data" / "nested_agents" / f"{TRUNK_SID}.jsonl" + +GROUPS_JS = """() => { + const isGroup = el => el.classList && el.classList.contains('children') + && el.querySelector(':scope > .message-node > .message.sidechain'); + const groups = Array.from(document.querySelectorAll('.children')).filter(isGroup); + return groups.map(g => { + const cs = getComputedStyle(g); + let depth = 0, el = g.parentElement; + while (el) { + if (isGroup(el)) depth += 1; + el = el.parentElement; + } + return { + marginLeft: cs.marginLeft, + borderWidth: cs.borderLeftWidth, + borderColor: cs.borderLeftColor, + enclosingGroups: depth, + }; + }); +}""" + + +class TestNestedAgentGroupCss: + @pytest.mark.browser + def test_every_spawn_boundary_indents_and_draws_the_line( + self, page: Page, tmp_path: Path + ) -> None: + entries = load_transcript(TRUNK, silent=True) + _integrate_agent_entries(entries) + html_path = tmp_path / "nested.html" + html_path.write_text(generate_html(entries, "Nested CSS"), encoding="utf-8") + page.set_viewport_size({"width": 1600, "height": 1200}) + page.goto(f"file://{html_path}") + + groups = page.evaluate(GROUPS_JS) + # 2×2: mid1 + mid2 (d1) with leaf22's surviving group inside mid2; + # chain: chain1 (d1) with chain2's group inside; interrupted (d1). + assert len(groups) == 6 + for g in groups: + assert g["marginLeft"] == "32px", g # 2em at 16px root + assert g["borderWidth"] == "2px", g + assert g["borderColor"] == "rgb(76, 175, 80)", g # tool-green + + # Depth accumulates structurally: exactly two groups are nested + # inside another group's subtree (depth-2 boundaries). + nested = [g for g in groups if g["enclosingGroups"] > 0] + assert len(nested) == 2, groups diff --git a/work/agent-hierarchies-design.md b/work/agent-hierarchies-design.md new file mode 100644 index 00000000..182f988d --- /dev/null +++ b/work/agent-hierarchies-design.md @@ -0,0 +1,205 @@ +# #213: Support hierarchies of agents — investigation & design + +> Status: design approved 2026-06-12 (see §7 decisions) — implementing. +> Branch: `dev/agent-hierarchies`. Builds on the #174 nested DOM / +> splice work (all merged through #217). + +Claude Code 2.1.172 added "Sub-agents can now spawn their own +sub-agents (up to 5 levels deep)". This doc records what the on-disk +data actually looks like (verified with generated sessions on CC +2.1.173), what breaks in the current pipeline at depth ≥ 2, and the +proposed design. + +## 1. Ground truth (CC 2.1.173, generated test sessions) + +Two scenarios were generated and inspected: a 2×2 fan-out (trunk → two +mid-agents → two leaves each) and a self-replicating linear recursion +chain. + +**On-disk layout — flat, at every depth:** + +``` +/.jsonl # trunk +//subagents/ + agent-.jsonl # one per agent, ANY depth + agent-.meta.json # {agentType, description, toolUseId} +``` + +There is no nested directory structure: a depth-5 agent's transcript +sits next to a depth-1 agent's in the same `subagents/` dir. + +**Entry shape inside agent files (all depths):** every entry carries +the *trunk* `sessionId`, `isSidechain: true`, and `agentId` = the +agent's own id. The root entry has `parentUuid: null`. Tool_result +entries inside agent files do NOT carry a top-level `toolUseResult` +(that enrichment exists only in the trunk file). + +**Parent → child linkage:** only the in-band metadata tail on the +spawning tool_result's content — `agentId: (use SendMessage with +to: '' to continue this agent)` + `` block (the format +`parse_agent_result_metadata` already understands). At trunk level the +structured `toolUseResult.agentId` additionally exists. + +**Child → parent linkage:** `agent-.meta.json` has `toolUseId` = +the id of the spawning `Agent` tool_use. Crucially this exists even +when the spawn never returned (see next point). + +**Interrupted spawns:** when the user interrupts, the spawning +tool_result arrives with `is_error: true` and the generic "The user +doesn't want to proceed…" text — no agentId tail, no `toolUseResult`. +The child transcripts are on disk but only `meta.json` links them. + +**The 5-level cap is NOT enforced.** A prompt instructing each agent +to spawn exactly one child reached depth **79** (one transcript per +level, ~17 KB each) before being externally interrupted; no cap error +ever surfaced at any level. Two consequences: (a) worth reporting +upstream; (b) the renderer must treat nesting depth as unbounded — +nothing may assume ≤ 5. + +**Sub-agents cannot start dynamic workflows.** A probe sub-agent +confirmed the `Workflow` tool is absent from a sub-agent's tool +surface (both direct and deferred/ToolSearch). So "a sub-agent +starting its own workflow" is impossible today via `Agent`-spawned +sub-agents; nested-workflow composition is de-scoped (§6). Whether a +*workflow* sub-agent can spawn `Agent` children is untested (follow-up +probe; the probe agent itself did have `Agent`). + +**Models flatten relayed spawn instructions.** A headless trunk asked +to "launch an agent that launches an agent" inlined the leaf task +directly (single level, fabricated nesting in its answer). Test +fixtures must come from real spawns, not from trusting the narrative. + +## 2. What already copes with depth (no work needed) + +- **Loading is recursive**: `load_transcript` follows agent references + in loaded agent files; the flat dir means uniform path resolution. +- **DAG layer**: `_integrate_agent_entries` has an explicit + nested-anchor path (cross-agent-boundary guard); flat synthetic sids + (`{trunk}#agent-{id}`) stay collision-free at any depth; + `_build_uuid_to_render_sid` maps a nested agent's uuids to its + *immediate* parent's sid by design. +- **CSS indentation**: structural (`.children` nesting); the #215 + comment explicitly anticipated arbitrary depth. +- **Fold machinery**: per-node fold bars + descendant counts computed + on the tree — depth-agnostic. +- **Result-tail parsing**: the `(use SendMessage …)` suffix is already + handled (`models.py` tail contract). +- **Detail levels**: sidechain stripping at LOW keys on the boolean + flag, which all depths carry. + +## 3. What breaks at depth ≥ 2 (verified) + +0. **Discovery** — only 2 of the 86 agent transcripts in the test + session load. Nested refs are collected from `toolUseResult.agentId` + which nested entries don't have; the interrupted chain head has no + ref at all. Everything below is moot until this is fixed. +1. **`_get_message_hierarchy_level`** — a boolean-sidechain model: + sidechain user/assistant → level 4, sidechain tools → level 5, + regardless of depth. A nested agent's entries flatten to its + parent agent's level (the level-stack can't tell them apart). +2. **`_relocate_subagent_blocks`** — an anchor must be a tool_result + *outside* any agent block (`"#agent-" not in sid`). A nested + agent's anchor lives inside its parent's block, so the nested block + falls into the defensive tail-append: wrong position AND flattened. +3. **Anchor identification** — in the flat-file format no raw entry + carries a cross-agent reference (the existing sidechain-anchor path + in `_integrate_agent_entries` predates it). The spawning tool_result + inside agent A's file has `agentId = A` (membership), and the + reference to child B exists only in the text tail / meta.json — the + single `agentId` field can't carry both meanings. +4. **CSS group borders** — the sidechain-group rule's + `:not(.sidechain)` parent filter deliberately suppresses group + lines below depth 1; there is no depth-differentiated color scheme + ("rethink the CSS levels" from the issue). +5. **Timeline** — all sidechain content lands in the single + `sidechain` lane regardless of depth (acceptable initially). +6. **Workflow splice** — `_graft_agent_sidechannel` re-renders agent + transcripts without passing workflow links down, so a workflow + started by a sub-agent would never splice (moot today, see §1). + +## 4. Proposed design + +### Phase A — loader: discovery + linking + +- Scan `/subagents/*.meta.json` once per session load into + `{toolUseId → (agentId, agentType, description)}`. +- Collect agent ids from BOTH `toolUseResult.agentId` (trunk, as + today) and meta-map hits on tool_use ids seen in loaded entries — + applied recursively as agent files load. This also recovers + interrupted chains (meta.json needs no tool_result). +- Introduce a dedicated **`spawned_agent_id`** field (synthetic, ours) + set on the spawning tool_result entry (or the tool_use when no + result exists) — keeping the entry's own `agentId` membership-only. +- `_integrate_agent_entries`: the anchor scan reads + `spawned_agent_id`; stamping (`{trunk}#agent-{id}`) is unchanged. + +### Phase B — hierarchy: depth-aware levels + nested relocation + +- Build `{sid → agent_depth}` (trunk 0, agent line = 1 + parent's). +- `_get_message_hierarchy_level` becomes depth-parameterized: each + depth gets a block of 3 levels — user/teammate `3d+1`, + assistant/thinking `3d+2`, tools/system-info `3d+3` (the current + 4/5 sidechain rules are the d=1 case, slightly compressed; the + special cases — task_notification, hooks — shift by `3d` likewise). +- `_relocate_subagent_blocks` becomes nested-aware: pre-build the + anchor→block map, emit blocks depth-first so a block's members are + themselves scanned as anchors for deeper blocks. The defensive + tail-append stays as the orphan fallback. +- `_cleanup_sidechain_duplicates` already recurses per spawn node and + should work once the tree is right — pin with tests. + +### Phase C — CSS levels & visuals (steering welcome) + +- Emit an `agent-depth-N` class on sidechain cards (N = the sid's + agent depth; styles defined for a 5-color cycle, deeper depths wrap + via `((N-1) mod 5) + 1` — no unbounded CSS). +- Generalize the sidechain group-border rule: key on the child's depth + class rather than `:not(.sidechain)`, with per-depth line colors + continuing the parent card's border color (the #215 color-pairing + principle). Depth 1 keeps today's tool-green; depths 2–5 need a + palette decision. +- Timeline: keep the single sidechain lane for now. + +### Phase D — fixture + tests + +- New `test/test_data/nested_agents/` distilled from the generated + sessions: the 2×2 fan-out, a 4-deep linear chain, and one + interrupted spawn (meta.json-only link). Sanitized ids/paths. +- Tests: discovery (incl. meta-only), DAG parentage, depth invariants + (each agent's entries strictly under its spawn pair), relocation + order, HTML structural assertions (depth classes + group borders), + detail-level behavior, markdown parity, style-guide/snapshot deltas. + +## 5. Suggested PR slicing + +1. **PR1**: Phases A+B+D — loader, hierarchy, fixtures, tests (the + structural meat; renders nested agents correctly with today's flat + sidechain styling). +2. **PR2**: Phase C — depth classes + border/color scheme + any + interactive polish round (mirrors the #215 pattern of a visual + round on real data). + +## 6. De-scoped / watching + +- **Nested dynamic workflows** — impossible today (no `Workflow` tool + on sub-agents). The splice's session-wide monotonic allocator and + the `workflow_links` map are compatible with a future recursive + graft (pass links into `_graft_agent_sidechannel`'s sub-render) if + this lands upstream. Follow-up probe: can a *workflow* agent spawn + `Agent` children, and where do those files go? +- **Timeline depth lanes / shading.** +- **Pagination**: a nested block split across page boundaries (the + depth-1 variant of this is pre-existing behavior). + +## 7. Decisions (2026-06-12) + +1. **Color ramp for depths 2–5**: not strictly needed (line counting + + indentation are clues enough), but include it if it's low-effort and + looks nicer — keep it simple, it's polish not structure (PR2). +2. **Depth badge on nested agent cards**: yes — useful (PR2; e.g. + nothing at d1, a small "d3" chip deeper). +3. **No upstream report** on the unenforced 5-level cap. Position: at + this tool's level, better to support anything that comes in — depth + is treated as unbounded throughout. +4. **PR slicing per §5 approved**: PR1 structural (Phases A+B+D), + PR2 visual (Phase C).