diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index 653e3e43..a27cdf63 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -167,6 +167,46 @@ an agent tracked from its first line. The renderer's 60 s TTL has the same shape. Anything that needs true liveness would have to come from the parent process, not from mtime. +## Subagent children inside a slug group (issue #128 ask 4, rehab-plan.md A3) + +- **The bug**: `buildSlugGroup()` used to append its sessions via the raw + `buildSessionItem(session)`, never through `appendSubagentChildren()` — only + the ungrouped/top-level render path (`buildSessionsList`) called that + helper. Any session rendered inside a slug group (i.e. any second-or-later + rerun of a schedule sharing a slug — the only real producer, see + `schedule-runner.js:createScheduleSession()`) silently lost its subagent + caret/children. +- **The fix**: `appendSubagentChildren()` was hoisted from a closure inside + `renderProjects()` to module scope (it never captured any of that + function's locals) so `buildSlugGroup(slug, sessions, subagentIndex)` can + call it directly for each session it renders, exactly like + `buildSessionsList` does for ungrouped sessions. +- **The knock-on bug this caused**: a slug-group `
` carries no + `dataset.sessionId` of its own, so the "orphan subagents" pass in + `buildSessionsList` — which built `allTopLevelIds` from + `item.element.dataset.sessionId` — never counted the sessions grouped + inside it as accounted-for. Their subagents were treated as parentless and + duplicated into the project's "Orphan subagents" bucket even after the fix + above attached them correctly inside the group. `collectTopLevelSessionIds(el)` + fixes this by walking into `el` for nested `[data-session-id]` session-items + (excluding subagent ones) when `el` itself isn't a session item. +- **Coverage**: `test/dom-slug-group-subagent-nesting.test.js` seeds two + schedule-rerun-shaped sessions sharing a slug plus a subagent parented to + one of them, and pins both the caret-attachment fix and the + no-duplicate-orphan fix (failed on both before the fix, confirmed by + reverting it locally during development). +- **A second knock-on bug (PR #134 review F1)**: nesting the subagent's + caret/children as DOM siblings inside the group means every DOM query + scoped to `.slug-group` that matches on `.session-item` alone now also + matches the nested subagent item (`buildSubagentItem` includes + `session-item` in its className for shared styling). The "Archive all + sessions in group" handler (`rebindSidebarEvents`, `.slug-group-archive-btn`) + had exactly this query and, unguarded, called `archiveSession`/`stopSession` + on the subagent's id. Fixed with the same `:not([data-subagent])` guard + already used elsewhere in this file (e.g. the per-item click wiring). Any + *new* query scoped to a slug-group's subtree must apply this guard too — + it's not automatic. + ## If you change this, also check - `eslint.config.js` `rendererCrossFileGlobals` — must list any new renderer-global functions (e.g. `showSubagentTranscript`, `drainViewerWatches`) or lint fails on `no-undef` diff --git a/public/sidebar.js b/public/sidebar.js index 1af1c0ab..5ebcf744 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -226,7 +226,56 @@ function buildSubagentItem(session) { return item; } -function buildSlugGroup(slug, sessions) { +// Shared by buildSessionsList and buildSlugGroup — see .ai/contexts/subagent-observability.md +function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { + const children = subagentIndex && subagentIndex.get(parentSessionId); + if (!children || children.length === 0) return; + + const expandedSet = getExpandedSubagents(); + const caretId = caretIdFor(parentSessionId); + const isExpanded = expandedSet.has(parentSessionId); + + const caret = document.createElement('div'); + caret.className = 'sidebar-children-caret js-stateful'; + caret.id = caretId; + if (isExpanded) caret.classList.add('expanded'); + if (parentHasActiveSubagent(parentSessionId)) caret.classList.add('has-running-child'); + caret.innerHTML = ` ${children.length} subagent${children.length !== 1 ? 's' : ''}`; + + const childrenContainer = document.createElement('div'); + childrenContainer.className = 'sidebar-subagents-container js-stateful'; + childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + childrenContainer.style.display = isExpanded ? '' : 'none'; + + for (const child of children) { + childrenContainer.appendChild(buildSubagentItem(child)); + } + + caret.addEventListener('click', (e) => { + e.stopPropagation(); + const open = childrenContainer.style.display !== 'none'; + childrenContainer.style.display = open ? 'none' : ''; + caret.classList.toggle('expanded', !open); + const set = getExpandedSubagents(); + if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); } + saveExpandedSubagents(set); + }); + + parentEl.after(caret); + caret.after(childrenContainer); +} + +// See .ai/contexts/subagent-observability.md (slug-group orphan-detection fix) +function collectTopLevelSessionIds(el) { + const ids = []; + if (el.dataset && el.dataset.sessionId && !el.dataset.subagent) ids.push(el.dataset.sessionId); + el.querySelectorAll('[data-session-id]').forEach((child) => { + if (!child.dataset.subagent) ids.push(child.dataset.sessionId); + }); + return ids; +} + +function buildSlugGroup(slug, sessions, subagentIndex) { const group = document.createElement('div'); const id = slugId(slug); const expanded = getExpandedSlugs().has(id); @@ -290,7 +339,9 @@ function buildSlugGroup(slug, sessions) { if (promoted.length > 0) { group.classList.add('has-promoted'); for (const session of promoted) { - sessionsContainer.appendChild(buildSessionItem(session)); + const sessionEl = buildSessionItem(session); + sessionsContainer.appendChild(sessionEl); + appendSubagentChildren(sessionEl, session.sessionId, subagentIndex); } if (rest.length > 0) { const moreBtn = document.createElement('div'); @@ -302,7 +353,9 @@ function buildSlugGroup(slug, sessions) { olderDiv.className = 'slug-group-older js-stateful'; olderDiv.id = 'sgo-' + id; for (const session of rest) { - olderDiv.appendChild(buildSessionItem(session)); + const sessionEl = buildSessionItem(session); + olderDiv.appendChild(sessionEl); + appendSubagentChildren(sessionEl, session.sessionId, subagentIndex); } sessionsContainer.appendChild(moreBtn); @@ -310,7 +363,9 @@ function buildSlugGroup(slug, sessions) { } } else { for (const session of sessions) { - sessionsContainer.appendChild(buildSessionItem(session)); + const sessionEl = buildSessionItem(session); + sessionsContainer.appendChild(sessionEl); + appendSubagentChildren(sessionEl, session.sessionId, subagentIndex); } } @@ -416,7 +471,7 @@ function renderProjects(projects, resort) { const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime())); const hasRunning = sessions.some(s => activePtyIds.has(s.sessionId) || pendingSessions.has(s.sessionId)); const hasPinned = sessions.some(s => s.starred); - const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions); + const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions, subagentIndex); allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, element }); } @@ -466,45 +521,6 @@ function renderProjects(projects, resort) { }; } - // Append subagent children beneath a session item element. - function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { - const children = subagentIndex && subagentIndex.get(parentSessionId); - if (!children || children.length === 0) return; - - const expandedSet = getExpandedSubagents(); - const caretId = caretIdFor(parentSessionId); - const isExpanded = expandedSet.has(parentSessionId); - - const caret = document.createElement('div'); - caret.className = 'sidebar-children-caret js-stateful'; - caret.id = caretId; - if (isExpanded) caret.classList.add('expanded'); - if (parentHasActiveSubagent(parentSessionId)) caret.classList.add('has-running-child'); - caret.innerHTML = ` ${children.length} subagent${children.length !== 1 ? 's' : ''}`; - - const childrenContainer = document.createElement('div'); - childrenContainer.className = 'sidebar-subagents-container js-stateful'; - childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); - childrenContainer.style.display = isExpanded ? '' : 'none'; - - for (const child of children) { - childrenContainer.appendChild(buildSubagentItem(child)); - } - - caret.addEventListener('click', (e) => { - e.stopPropagation(); - const open = childrenContainer.style.display !== 'none'; - childrenContainer.style.display = open ? 'none' : ''; - caret.classList.toggle('expanded', !open); - const set = getExpandedSubagents(); - if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); } - saveExpandedSubagents(set); - }); - - parentEl.after(caret); - caret.after(childrenContainer); - } - // Build the sessions list DOM (shared between projects and worktrees) function buildSessionsList(fId, visible, older, subagentIndex, projectPath) { const sessionsList = document.createElement('div'); @@ -534,9 +550,10 @@ function renderProjects(projects, resort) { sessionsList.appendChild(olderList); } - // Orphan subagents: children whose parentSessionId has no top-level session in this project + // Orphan subagents: children whose parentSessionId has no top-level session in this project. + // See .ai/contexts/subagent-observability.md for why collectTopLevelSessionIds is needed here. if (subagentIndex) { - const allTopLevelIds = new Set([...visible, ...older].map(i => i.element.dataset && i.element.dataset.sessionId).filter(Boolean)); + const allTopLevelIds = new Set([...visible, ...older].flatMap(i => collectTopLevelSessionIds(i.element))); const orphans = []; for (const [parentId, kids] of subagentIndex) { if (!allTopLevelIds.has(parentId)) { @@ -892,7 +909,7 @@ function rebindSidebarEvents(projects) { archiveBtn.onclick = async (e) => { e.stopPropagation(); const group = header.parentElement; - const sessionItems = group.querySelectorAll('.session-item'); + const sessionItems = group.querySelectorAll('.session-item:not([data-subagent])'); for (const item of sessionItems) { const sid = item.dataset.sessionId; const session = sessionMap.get(sid); diff --git a/test/dom-slug-group-subagent-nesting.test.js b/test/dom-slug-group-subagent-nesting.test.js new file mode 100644 index 00000000..8e8db37d --- /dev/null +++ b/test/dom-slug-group-subagent-nesting.test.js @@ -0,0 +1,208 @@ +// Regression coverage for issue #128 (ask 4) / rehab-plan.md A3+A4 — subagent +// children rendered inside a slug group used to vanish from the sidebar. +// +// buildSlugGroup() appended its sessions via the raw buildSessionItem(session), +// never through appendSubagentChildren — only the top-level (non-grouped) +// render path called that helper. Any session rendered inside a slug group +// therefore lost its subagent caret/children, and — because the group element +// itself carries no data-session-id — the orphan-subagents pass in +// buildSessionsList() didn't recognize the grouped session as "already +// accounted for" either, so its subagents were duplicated into the project's +// "Orphan subagents" bucket instead. +// +// The fixture below reproduces the one real producer of shared slugs across +// top-level sessions in this app: schedule-runner.js's createScheduleSession() +// writes `slug: schedule.slug` on the JSONL's first line for every rerun of the +// same schedule (schedule-runner.js:176). Two reruns of the same schedule share +// a slug and land in session_cache (session-cache.js:buildProjectsFromCache) +// with identical `slug`, distinct `sessionId`/`modified`, and no +// `parentSessionId` — the shape asserted here. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom } = require('./dom-setup'); + +function scheduleRerunProject({ subagentParentId = 'sched-run-2', ...overrides } = {}) { + const baseTime = Date.parse('2026-08-20T09:00:00Z'); + const t = (offsetMs) => new Date(baseTime + offsetMs).toISOString(); + // summary as read-session-file.js derives it for a createScheduleSession() + // JSONL: the first user message is `'Scheduled Task: ' + schedule.prompt`, + // which doesn't match the tag pattern, so it + // falls through to text.slice(0, 120). + const summary = 'Scheduled Task: Check Hacker News for the current top article and any posts related to AI docume'; + + return { + projectPath: '/home/dev/hn-watcher', + sessions: [ + { + sessionId: 'sched-run-1', + slug: 'hn-top-articles', + summary, + firstPrompt: summary, + modified: t(-120000), // an earlier rerun + messageCount: 1, + starred: false, + archived: 0, + }, + { + sessionId: 'sched-run-2', + slug: 'hn-top-articles', + summary, + firstPrompt: summary, + modified: t(0), // the current (running) rerun + messageCount: 1, + starred: false, + archived: 0, + }, + { + sessionId: `sub:${subagentParentId}:agent-1`, + parentSessionId: subagentParentId, + agentId: 'agent-1', + subagentType: 'explore', + description: 'explore subagent', + modified: t(-1000), + messageCount: 1, + }, + ], + ...overrides, + }; +} + +test('a session rendered inside a slug group still gets its subagent children attached', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.activePtyIds.add('sched-run-2'); + ctx.sidebar.renderProjects([scheduleRerunProject()], true); + + const group = ctx.document.getElementById('slug-hn-top-articles'); + assert.ok(group, 'the two schedule reruns must be grouped by shared slug'); + assert.ok(group.classList.contains('slug-group'), 'grouped element must carry slug-group'); + + const groupedSessionItems = group.querySelectorAll('.session-item:not([data-subagent])'); + assert.equal(groupedSessionItems.length, 2, 'both reruns render as session-items inside the group'); + + const caret = ctx.document.getElementById('sub-caret-sched-run-2'); + assert.ok(caret, 'subagent caret for the grouped, running rerun must exist'); + assert.ok(group.contains(caret), 'the caret must be nested inside the slug group, next to its parent session-item'); + + const subagentItem = ctx.document.getElementById('si-sub:sched-run-2:agent-1'); + assert.ok(subagentItem, 'the subagent item must be rendered'); + assert.ok(group.contains(subagentItem), 'the subagent item must be nested inside the slug group, not dropped to the orphan bucket'); + } finally { + ctx.destroy(); + } +}); + +test('the grouped session\'s subagent is not duplicated into the project\'s orphan-subagents bucket', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.activePtyIds.add('sched-run-2'); + ctx.sidebar.renderProjects([scheduleRerunProject()], true); + + const orphanGroup = ctx.document.querySelector('.sidebar-orphan-subagents'); + assert.ok(!orphanGroup, 'a subagent whose parent is a grouped session is not an orphan — no orphan bucket should render'); + } finally { + ctx.destroy(); + } +}); + +test('the slug-group dot lights up while one of the grouped, nested sessions has an active PTY', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.activePtyIds.add('sched-run-2'); + ctx.sidebar.renderProjects([scheduleRerunProject()], true); + + const dot = ctx.document.querySelector('#slug-hn-top-articles .slug-group-dot'); + assert.ok(dot, 'slug-group-dot must be rendered'); + assert.ok(dot.classList.contains('running'), 'group dot must reflect that sched-run-2 has an active PTY'); + } finally { + ctx.destroy(); + } +}); + +// PR #134 review F1 — nesting the subagent's caret/children as DOM siblings +// inside the group (the fix above) means group.querySelectorAll('.session-item') +// in the "Archive all sessions in group" handler now also matches the nested +// subagent item (buildSubagentItem's className includes 'session-item' for +// shared styling), so the handler must exclude it the same way every other +// subagent-item consumer in this file does (:not([data-subagent])). +test('"Archive all sessions in group" only archives the two top-level reruns, not the nested subagent', async () => { + const ctx = setupSidebarDom(); + try { + ctx.window.activePtyIds.add('sched-run-2'); + ctx.window.sessionMap.set('sched-run-1', { sessionId: 'sched-run-1', archived: 0 }); + ctx.window.sessionMap.set('sched-run-2', { sessionId: 'sched-run-2', archived: 0 }); + ctx.window.sessionMap.set('sub:sched-run-2:agent-1', { sessionId: 'sub:sched-run-2:agent-1', archived: 0 }); + + const archiveCalls = []; + const stopCalls = []; + ctx.window.api.archiveSession = (sid, val) => { archiveCalls.push([sid, val]); return Promise.resolve({ ok: true }); }; + ctx.window.api.stopSession = (sid) => { stopCalls.push(sid); return Promise.resolve({ ok: true }); }; + + ctx.sidebar.renderProjects([scheduleRerunProject()], true); + + const archiveBtn = ctx.document.querySelector('#slug-hn-top-articles .slug-group-archive-btn'); + assert.ok(archiveBtn, 'slug-group-archive-btn must be rendered'); + await archiveBtn.onclick({ stopPropagation: () => {} }); + + const archivedIds = archiveCalls.map(([sid]) => sid).sort(); + assert.deepEqual(archivedIds, ['sched-run-1', 'sched-run-2'], + 'only the two top-level reruns must be archived — the nested subagent must not reach archiveSession'); + assert.ok(!stopCalls.includes('sub:sched-run-2:agent-1'), + 'the nested subagent must not reach stopSession either'); + } finally { + ctx.destroy(); + } +}); + +// Adversarial review of #134 found buildSlugGroup() calling appendSubagentChildren +// from three separate loops (promoted / rest-under-"more" / all-sessions-else) and +// only the first was exercised — see .ai/contexts/subagent-observability.md. +test('a session in the "+N more" (non-promoted) part of a slug group still gets its subagent children attached', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.activePtyIds.add('sched-run-2'); // only sched-run-2 is promoted; sched-run-1 lands in "rest" + ctx.sidebar.renderProjects([scheduleRerunProject({ subagentParentId: 'sched-run-1' })], true); + + const group = ctx.document.getElementById('slug-hn-top-articles'); + assert.ok(group, 'the two schedule reruns must be grouped by shared slug'); + assert.ok(group.classList.contains('has-promoted'), 'sched-run-2 being active must promote it, pushing sched-run-1 into rest'); + + const olderDiv = ctx.document.getElementById('sgo-slug-hn-top-articles'); + assert.ok(olderDiv, 'the "+N more" container must be rendered since rest.length > 0'); + + const caret = ctx.document.getElementById('sub-caret-sched-run-1'); + assert.ok(caret, 'subagent caret for the non-promoted rerun must exist'); + assert.ok(olderDiv.contains(caret), 'the caret must be nested inside the "+N more" container, next to its parent session-item'); + + const subagentItem = ctx.document.getElementById('si-sub:sched-run-1:agent-1'); + assert.ok(subagentItem, 'the subagent item must be rendered'); + assert.ok(olderDiv.contains(subagentItem), 'the subagent item must be nested inside the "+N more" container, not dropped to the orphan bucket'); + } finally { + ctx.destroy(); + } +}); + +test('a session in a slug group with no active PTY at all still gets its subagent children attached', () => { + const ctx = setupSidebarDom(); + try { + // No session in this group has an active PTY, so buildSlugGroup() takes + // the plain "all sessions" branch rather than the promoted/rest split. + ctx.sidebar.renderProjects([scheduleRerunProject({ subagentParentId: 'sched-run-2' })], true); + + const group = ctx.document.getElementById('slug-hn-top-articles'); + assert.ok(group, 'the two schedule reruns must be grouped by shared slug'); + assert.ok(!group.classList.contains('has-promoted'), 'no session is active, so the group must not be in the promoted state'); + + const caret = ctx.document.getElementById('sub-caret-sched-run-2'); + assert.ok(caret, 'subagent caret must exist even when no session in the group is running'); + assert.ok(group.contains(caret), 'the caret must be nested inside the slug group, next to its parent session-item'); + + const subagentItem = ctx.document.getElementById('si-sub:sched-run-2:agent-1'); + assert.ok(subagentItem, 'the subagent item must be rendered'); + assert.ok(group.contains(subagentItem), 'the subagent item must be nested inside the slug group, not dropped to the orphan bucket'); + } finally { + ctx.destroy(); + } +});