From d0dff0dedc421804d01dfe0806e988bd74175d51 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 23:15:00 +0200 Subject: [PATCH 01/12] work: investigation + design for agent hierarchies (#213) Ground truth from generated CC 2.1.173 sessions: flat subagents/ layout at every depth, tail/meta.json linkage, unenforced 5-level cap, no Workflow tool on sub-agents. Verified breaks: discovery (toolUseResult-only collection), boolean-sidechain hierarchy levels, trunk-only relocation anchors, depth-blind group-border CSS. Proposes loader linking via meta.json (spawned_agent_id), depth- parameterized hierarchy levels, nested-aware relocation, and a 5-color depth cycle for group borders. Co-Authored-By: Claude Fable 5 --- work/agent-hierarchies-design.md | 201 +++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 work/agent-hierarchies-design.md diff --git a/work/agent-hierarchies-design.md b/work/agent-hierarchies-design.md new file mode 100644 index 00000000..63ee1644 --- /dev/null +++ b/work/agent-hierarchies-design.md @@ -0,0 +1,201 @@ +# #213: Support hierarchies of agents — investigation & design + +> Status: investigation complete, design proposed — awaiting steering. +> 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. Open questions + +1. Color ramp for depths 2–5 (and the >5 wrap) — preferences? +2. A depth badge on nested agent cards (e.g. nothing at d1, "d3" chip + deeper), or rely purely on indentation + line colors? +3. Report the unenforced 5-level cap upstream? (Clean repro available; + one transcript per level, linear chain to depth 79.) +4. PR slicing per §5 OK? From 98188165060b86aeed550b2720dca8e7a035fc0b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 00:48:30 +0200 Subject: [PATCH 02/12] work: record steering decisions for #213 (badge yes, ramp optional, no upstream report, slicing OK) Co-Authored-By: Claude Fable 5 --- work/agent-hierarchies-design.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/work/agent-hierarchies-design.md b/work/agent-hierarchies-design.md index 63ee1644..182f988d 100644 --- a/work/agent-hierarchies-design.md +++ b/work/agent-hierarchies-design.md @@ -1,6 +1,6 @@ # #213: Support hierarchies of agents — investigation & design -> Status: investigation complete, design proposed — awaiting steering. +> 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). @@ -191,11 +191,15 @@ fixtures must come from real spawns, not from trusting the narrative. - **Pagination**: a nested block split across page boundaries (the depth-1 variant of this is pre-existing behavior). -## 7. Open questions - -1. Color ramp for depths 2–5 (and the >5 wrap) — preferences? -2. A depth badge on nested agent cards (e.g. nothing at d1, "d3" chip - deeper), or rely purely on indentation + line colors? -3. Report the unenforced 5-level cap upstream? (Clean repro available; - one transcript per level, linear chain to depth 79.) -4. PR slicing per §5 OK? +## 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). From 8bfcacc3151734487c8216f0a1f6b7d0a02df346 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:14:59 +0200 Subject: [PATCH 03/12] Discover nested sub-agent transcripts via meta.json sidecars (#213) Claude Code 2.1.172+ lets sub-agents spawn their own sub-agents; all transcripts land flat in the trunk's subagents/ dir, but a nested spawn's tool_result carries no toolUseResult.agentId (trunk-only enrichment) and an interrupted spawn has no usable tool_result at all - so only depth-1 agents were discovered. The agent-.meta.json sidecar's toolUseId links every spawn at any depth. The loader scans the sidecars once per load (memoized per directory), stamps the resolved id on the spawning entry as the new spawnedAgentId field (membership stays in agentId - inside an agent transcript the two necessarily differ), unions the ids into the agent-file loading, and inserts each child's entries right after its spawn entry, which nests recursively loaded sub-sub-agents at the right flat-order position. _integrate_agent_entries prefers these sidecar anchors over the legacy heuristics. Co-Authored-By: Claude Fable 5 --- claude_code_log/converter.py | 147 ++++++++++++++++++++-- claude_code_log/factories/meta_factory.py | 1 + claude_code_log/models.py | 12 ++ 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index e44302a0..74def038 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: @@ -383,6 +389,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 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 + 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 +751,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 +775,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/models.py b/claude_code_log/models.py index 5ea9e6b0..d5dfdb62 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -208,6 +208,14 @@ 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. + spawnedAgentId: Optional[str] = None class UserTranscriptEntry(BaseTranscriptEntry): @@ -390,6 +398,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) From 0cfa2b284464bdda86a6fd4d7a96829a060d5948 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:14:59 +0200 Subject: [PATCH 04/12] Depth-aware hierarchy levels + recursive block relocation (#213) The level-stack hard-coded a boolean-sidechain model (assistant 4 / tools 5), so a nested agent's entries flattened to its parent agent's level; and _relocate_subagent_blocks required its anchor to sit outside any agent block, so a nested block fell into the defensive tail-append. Levels now shift by 2 per extra nesting depth (the span the depth-1 sidechain rules already use), with depth chased through the spawned_agent_id links; agent lines without one default to depth 1, reproducing the previous behavior exactly. Relocation emits blocks recursively, so each block lands right after its spawn anchor even when that anchor lives inside another agent's block. Depth is treated as unbounded throughout - the advertised 5-level cap is not enforced by the harness (observed depth 79 in the wild). Co-Authored-By: Claude Fable 5 --- claude_code_log/renderer.py | 108 ++++++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 18 deletions(-) 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: From 5ab58d7a01e99e4d74c28b8eb48eba060aa06e01 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:14:59 +0200 Subject: [PATCH 05/12] Nested-agents fixture + pinning tests (#213) Synthesized fixture mirroring the CC 2.1.173 on-disk shape (flat subagents/ dir, in-band agentId tails, trunk-only toolUseResult, meta.json sidecars): a 2x2 fan-out, a 3-deep chain, and an interrupted spawn whose transcript links via the sidecar alone. Tests pin sidecar discovery at every depth, DAG parentage and depth histogram, the spawner-path nesting invariant on the template tree (incl. the full-collapse of verbatim transcripts vs. the survival of a divergent one), and HTML/Markdown rendering of nested content. Co-Authored-By: Claude Fable 5 --- scripts/gen_nested_agents_fixture.py | 399 ++++++++++++++++++ ...33330000-0000-4000-8000-000000000001.jsonl | 9 + .../subagents/agent-nschain1.jsonl | 4 + .../subagents/agent-nschain1.meta.json | 1 + .../subagents/agent-nschain2.jsonl | 4 + .../subagents/agent-nschain2.meta.json | 1 + .../subagents/agent-nschain3.jsonl | 2 + .../subagents/agent-nschain3.meta.json | 1 + .../subagents/agent-nsintr01.jsonl | 2 + .../subagents/agent-nsintr01.meta.json | 1 + .../subagents/agent-nsleaf11.jsonl | 2 + .../subagents/agent-nsleaf11.meta.json | 1 + .../subagents/agent-nsleaf12.jsonl | 2 + .../subagents/agent-nsleaf12.meta.json | 1 + .../subagents/agent-nsleaf21.jsonl | 2 + .../subagents/agent-nsleaf21.meta.json | 1 + .../subagents/agent-nsleaf22.jsonl | 2 + .../subagents/agent-nsleaf22.meta.json | 1 + .../subagents/agent-nsmid001.jsonl | 5 + .../subagents/agent-nsmid001.meta.json | 1 + .../subagents/agent-nsmid002.jsonl | 5 + .../subagents/agent-nsmid002.meta.json | 1 + test/test_nested_agents.py | 230 ++++++++++ 23 files changed, 678 insertions(+) create mode 100644 scripts/gen_nested_agents_fixture.py create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.json create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonl create mode 100644 test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.json create mode 100644 test/test_nested_agents.py diff --git a/scripts/gen_nested_agents_fixture.py b/scripts/gen_nested_agents_fixture.py new file mode 100644 index 00000000..1f6d2437 --- /dev/null +++ b/scripts/gen_nested_agents_fixture.py @@ -0,0 +1,399 @@ +#!/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 +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: + 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/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..fad1b190 --- /dev/null +++ b/test/test_nested_agents.py @@ -0,0 +1,230 @@ +"""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 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 From 43d7589d2d996753cb1748566a84dcef271f6c45 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:14:59 +0200 Subject: [PATCH 06/12] dev-docs: nested agent hierarchies as-built reference (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New agents.md §5 (flat on-disk shape, the two linkage directions, spawnedAgentId semantics, depth-shifted levels, dedup collapse, fixture pointer) + cross-refs in dag.md and message-hierarchy.md. Co-Authored-By: Claude Fable 5 --- dev-docs/agents.md | 92 +++++++++++++++++++++++++++++++++++ dev-docs/dag.md | 6 ++- dev-docs/message-hierarchy.md | 4 ++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/dev-docs/agents.md b/dev-docs/agents.md index 1f35d8a6..bb49a100 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,92 @@ 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. + +### 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. From e914644fbf95f3d5af68e701f944cff3be10ea64 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:23:03 +0200 Subject: [PATCH 07/12] dev-docs: note the practical recursion bound at extreme nesting depth (review advisory) Co-Authored-By: Claude Fable 5 --- dev-docs/agents.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev-docs/agents.md b/dev-docs/agents.md index bb49a100..a2ba8e0b 100644 --- a/dev-docs/agents.md +++ b/dev-docs/agents.md @@ -219,6 +219,10 @@ detail-level behaviour). 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 From ccacea4051b43648d079bf63e5d2f1d9bcdcf78b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:50:12 +0200 Subject: [PATCH 08/12] Cache: sidecar inputs join invalidation (CR #218) Since spawn discovery reads the agent-*.meta.json sidecars, a sidecar appearing after a transcript was cached (nested spawns never touch the trunk file's mtime) would be served stale forever. cached_files gains a subagents_fingerprint column (migration 007): sidecar count + newest sidecar mtime, stored at save and compared on every read. Pre-007 NULL rows stay valid only for files with no sidecars today, so legacy caches don't mass-invalidate while sessions WITH sidecars reparse once. Co-Authored-By: Claude Fable 5 --- claude_code_log/cache.py | 60 +++++++++++- .../migrations/007_subagents_fingerprint.sql | 16 ++++ test/test_nested_agents.py | 91 +++++++++++++++++++ 3 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 claude_code_log/migrations/007_subagents_fingerprint.sql diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 951c751e..1c6318ce 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -220,6 +220,40 @@ 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. + """ + 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 +505,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 +517,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.""" @@ -579,13 +626,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 +643,7 @@ def save_cached_entries( source_mtime, cached_mtime, len(entries), + subagents_fingerprint(jsonl_path), ), ) 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/test/test_nested_agents.py b/test/test_nested_agents.py index fad1b190..a17d02b3 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -208,6 +208,97 @@ def test_interrupted_transcript_nests_under_error_result(self) -> None: 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) + + +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 From 8102bb05e61ebe08356a5df64fce930bd2b30d05 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:50:12 +0200 Subject: [PATCH 09/12] Never overwrite a spawn anchor on multi-spawn entries (CR #218) A single spawnedAgentId per entry matches the data: Claude Code streams one content block per assistant entry (0 multi-spawn entries across 300 recent transcripts; parallel spawns arrive as separate entries) and tool_results anchor 1:1 on their own entries. The only theoretical collision - one entry carrying several RESULTLESS spawn tool_uses - now keeps the first link deterministically (sorted iteration) instead of silently overwriting, and the extra transcript still loads via the relocation tail-append. Invariant documented on the field. Co-Authored-By: Claude Fable 5 --- claude_code_log/converter.py | 13 ++++++++++++- claude_code_log/models.py | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 74def038..d5bce27f 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -556,7 +556,7 @@ def _apply_subagent_meta_links( 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 meta_map.items(): + 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: @@ -565,6 +565,17 @@ def _apply_subagent_meta_links( 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 diff --git a/claude_code_log/models.py b/claude_code_log/models.py index d5dfdb62..795cb107 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -215,6 +215,13 @@ class BaseTranscriptEntry(BaseModel): # *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 From 9be83c3acbb4ea23ec29842d1a61f00116489f23 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 01:50:12 +0200 Subject: [PATCH 10/12] Fixture generator: clear stale artifacts before regenerating (CR #218) Co-Authored-By: Claude Fable 5 --- scripts/gen_nested_agents_fixture.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/gen_nested_agents_fixture.py b/scripts/gen_nested_agents_fixture.py index 1f6d2437..3f24148b 100644 --- a/scripts/gen_nested_agents_fixture.py +++ b/scripts/gen_nested_agents_fixture.py @@ -35,6 +35,7 @@ from __future__ import annotations import json +import shutil from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -309,6 +310,9 @@ def _write_jsonl(path: Path, rows: list[dict]) -> None: 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()) From d8c84d43fd20d58c2a06d6443825d48b47e4b514 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 02:00:19 +0200 Subject: [PATCH 11/12] Capture the sidecar fingerprint at parse time, not save time Closes the TOCTOU window in the cache invalidation: a sidecar landing between the parse's sidecar scan and save_cached_entries was fingerprinted as covered without having been parsed, validating a stale parse until the NEXT sidecar change. The fingerprint is now captured before the parse and threaded to the save, so a mid-parse sidecar mismatches on the next read and forces a reparse (over-invalidation, never under). Also documents the deliberate trunk-candidate divergence between the fingerprint and the parse scan. Co-Authored-By: Claude Fable 5 --- claude_code_log/cache.py | 25 ++++++++++++++++++++++--- claude_code_log/converter.py | 13 +++++++++++-- test/test_nested_agents.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 1c6318ce..62f9d250 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -235,6 +235,12 @@ def subagents_fingerprint(jsonl_path: Path) -> str: (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": @@ -611,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 @@ -643,7 +662,7 @@ def save_cached_entries( source_mtime, cached_mtime, len(entries), - subagents_fingerprint(jsonl_path), + subagents_fp, ), ) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index d5bce27f..6f7b555d 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -265,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 @@ -473,7 +480,9 @@ def load_transcript( # Save to cache if cache manager is available if cache_manager is not None: - cache_manager.save_cached_entries(jsonl_path, messages) + cache_manager.save_cached_entries( + jsonl_path, messages, subagents_fp=subagents_fp + ) return messages diff --git a/test/test_nested_agents.py b/test/test_nested_agents.py index a17d02b3..509a9173 100644 --- a/test/test_nested_agents.py +++ b/test/test_nested_agents.py @@ -242,6 +242,35 @@ def test_new_sidecar_invalidates_cached_trunk(self, tmp_path: Path) -> None: # 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: From 7b773b172acd545641f222c0bf035969c3576401 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 12 Jun 2026 08:29:06 +0200 Subject: [PATCH 12/12] Indent + group line at every spawn boundary, not just depth 1 (#213) The sidechain group rule's :not(.sidechain) parent filter meant only the depth-1 boundary got its 2em indent and tool-green line - nested agent transcripts rendered completely flat, with no depth cue at all. The rule now matches at every depth (each spawn boundary adds its own step + line, accumulating through the DOM), covers tool_use parents too (running/interrupted spawns attach there), and excludes workflow agent cards (also tool_use-classed; their side-channel line stays grey, pinned by the existing workflow browser test). New browser test pins the per-depth contract. Per-depth line colors + depth badges follow in the visual PR. Co-Authored-By: Claude Fable 5 --- .../templates/components/message_styles.css | 29 ++- test/__snapshots__/test_snapshot_html.ambr | 203 ++++++++++++------ test/test_nested_agents_browser.py | 67 ++++++ 3 files changed, 219 insertions(+), 80 deletions(-) create mode 100644 test/test_nested_agents_browser.py 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/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_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