feat(native-chat): the sidebar lists running subagents; the strip, running then the newest finished

The host keeps every child record; what each surface lists is picked from them on every read, so
nothing is stored twice. The status summary, which every session list reads, now carries only
running children (and a finished one whose shell still runs, which reads monitoring): a finished
or failed subagent leaves the sidebar and stays in the chat's strip. The strip lists every running
child, then the newest finished ones, 100 rows in all; more than 100 running all show.

This matches common practice: sidebars show live subagents, and finished ones stay in the chat's
panel, newest first. No wire field is added. An older client reads fewer rows: its legacy task
lists were already live-only in the summary, and the strip's settled tasks come from the same
bounded roster.
This commit is contained in:
Brennan Benson
2026-09-29 00:59:49 -07:00
parent e528daea6c
commit 690580968b
9 changed files with 258 additions and 40 deletions
@@ -5,6 +5,7 @@ import type {
} from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy'
import { structuredStripChildWork } from '../../../shared/structured-agent-session-child-work-selection'
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
import type {
AgentSessionSubscribers,
@@ -16,8 +17,9 @@ import type {
} from './structured-agent-session-host-types'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
/** The chat strip's roster: the host's child records for one session, as the same views the status
* summary carries, with the legacy task rows an older client reads derived from them. */
/** The chat strip's roster: the host's child records for one session (every running child, then
* the newest finished ones, up to the strip's row budget), with the legacy task rows an older
* client reads derived from them. */
export class StructuredAgentSessionBackgroundTaskChannel {
private readonly published = new Map<string, string>()
@@ -90,10 +92,11 @@ export class StructuredAgentSessionBackgroundTaskChannel {
private state(sessionId: string): AgentSessionBackgroundTaskState | null | undefined {
const session = this.sessions.get(sessionId)
const views = session ? this.readChildWork(sessionId) : undefined
if (!session || views === undefined) {
const stored = session ? this.readChildWork(sessionId) : undefined
if (!session || stored === undefined) {
return undefined
}
const views = structuredStripChildWork(stored)
const stops = this.deps.adapter.backgroundTaskStops?.(sessionId)
if (views.length === 0) {
// As before: a session no live provider holds says nothing, a live one says "none".
@@ -0,0 +1,110 @@
// What each surface lists of a session's child records: the sidebar its running children, the
// chat's strip every running child and then the newest finished ones, up to 100 rows.
import { describe, expect, it } from 'vitest'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import {
STRUCTURED_STRIP_CHILD_WORK_LIMIT as LIMIT,
structuredSidebarChildWork,
structuredStripChildWork
} from '../../../shared/structured-agent-session-child-work-selection'
import {
attach,
hostTestState,
serveHostTestChildWork
} from './structured-agent-session-host-test-harness'
import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data'
function running(id: string, over: Partial<AgentChildWorkView> = {}): AgentChildWorkView {
return {
id,
kind: 'agent',
state: 'working',
membership: 'live',
firstObservedAt: 1,
observedAt: 1,
stoppable: false,
invocation: { invocationId: `spawn-${id}`, generation: 1 },
...over
}
}
function finished(id: string, settledAt: number): AgentChildWorkView {
return running(id, {
state: 'done',
membership: 'settled',
outcome: 'succeeded',
observedAt: settledAt,
settledAt
})
}
const ids = (views: readonly AgentChildWorkView[]) => views.map((view) => view.id)
describe('the sidebar lists running children only', () => {
it('drops finished and failed children, and keeps a finished one whose shell still runs', () => {
const failed = { ...finished('failed', 5), outcome: 'failed' as const }
const views = [
running('working'),
finished('done', 4),
failed,
finished('owner', 3),
running('shell', { kind: 'command', parentChildWorkId: 'owner' })
]
expect(ids(structuredSidebarChildWork(views))).toEqual(['working', 'owner', 'shell'])
})
})
describe('the strip lists running children, then the newest finished, up to 100 rows', () => {
it('lists every child while they fit', () => {
const views = [running('a'), finished('b', 2), finished('c', 3)]
expect(ids(structuredStripChildWork(views))).toEqual(['a', 'b', 'c'])
})
it('keeps every running child and the newest finished ones, in the order the store holds them', () => {
const finishedViews = Array.from({ length: 150 }, (_, index) =>
finished(`done-${index}`, 1_000 + index)
)
const runningViews = Array.from({ length: 10 }, (_, index) => running(`run-${index}`))
const listed = structuredStripChildWork([...finishedViews, ...runningViews])
expect(listed).toHaveLength(LIMIT)
// The 90 newest finished children (settled latest), then the running ones, as the store orders.
expect(ids(listed)).toEqual([
...Array.from({ length: 90 }, (_, index) => `done-${index + 60}`),
...ids(runningViews)
])
})
it('shows every running child when more than 100 run, and no finished one', () => {
const runningViews = Array.from({ length: 120 }, (_, index) => running(`run-${index}`))
const listed = structuredStripChildWork([finished('done', 5), ...runningViews])
expect(ids(listed)).toEqual(ids(runningViews))
})
})
describe("the host's strip channel and summary list what these pick", () => {
it('sends the strip the bounded roster and the sidebar the running children', async () => {
const records = [
...Array.from({ length: 150 }, (_, index) => finished(`done-${index}`, 1_000 + index)),
running('run')
]
serveHostTestChildWork(() => records)
await attach()
const { host } = hostTestState()
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
const children = page.ok ? (page.page.backgroundTasks?.children ?? []) : []
expect(children).toHaveLength(LIMIT)
expect(children.at(-1)?.id).toBe('run')
expect(children[0]?.id).toBe('done-51')
const summaries: string[][] = []
host.subscribeStatus({
id: 'list',
emit: (event) => {
if (event.type === 'snapshot') {
summaries.push(event.sessions.flatMap((session) => ids(session.children ?? [])))
}
}
})
expect(summaries.at(-1)).toEqual(['run'])
})
})
@@ -14,6 +14,7 @@ import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-w
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { agentChildWorkViewsEqual } from '../../../shared/agent-status-child-work-view-wire'
import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy'
import { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection'
/** An evidence clock that advanced by less than this does not re-broadcast a summary.
* Invariant: every reader of the summary's child clocks shows staleness no finer than this (today,
@@ -27,16 +28,18 @@ export type StructuredStatusChildWork = {
backgroundTasks?: AgentSessionBackgroundTask[]
}
/** The summary's child fields: the views without usage, and the live legacy tasks an older client
* reads, derived from the same views so the two cannot disagree. */
/** The summary's child fields: the running views without usage, since the sidebar lists running
* children only, and the live legacy tasks an older client reads, derived from the same views so
* the two cannot disagree. */
export function structuredStatusChildWork(
views: readonly AgentChildWorkView[] | undefined,
provider: AgentSessionHandleProvider
): StructuredStatusChildWork {
if (!views || views.length === 0) {
const running = views ? structuredSidebarChildWork(views) : []
if (running.length === 0) {
return {}
}
const children = views.map(({ totalTokens: _totalTokens, ...view }) => view)
const children = running.map(({ totalTokens: _totalTokens, ...view }) => view)
// `tasks` only, the live rows: an old client folds every listed task into the parent, and
// reads a failed one's legacy `blocked` as still running. Dies with the legacy shapes; see the
// death condition in `structured-agent-session-child-work-legacy`.
@@ -186,7 +186,8 @@ describe('structured status summary child records', () => {
})
})
it('lists a finished child on the summary, never in the task list an older client folds', async () => {
// The sidebar lists running children only; a finished one stays in the chat's strip.
it('lists only running children on the summary, and no finished one in any of its lists', async () => {
const { feed, events, views } = await feedWithChildren()
const failed = childView({
id: 'child-2',
@@ -203,8 +204,26 @@ describe('structured status summary child records', () => {
// An older client reads a failed task's legacy `blocked` as still running.
expect(lastSummary(events)).toMatchObject({
backgroundTasks: [LEGACY_TASK],
children: [childView(), failed]
children: [childView()]
})
views.current = () => [failed]
feed.publish(SESSION)
expect(lastSummary(events)).not.toHaveProperty('children')
expect(lastSummary(events)).not.toHaveProperty('backgroundTasks')
})
it('keeps a finished child on the summary while a shell it launched still runs', async () => {
const { feed, events, views } = await feedWithChildren()
const owner = childView({ state: 'done', membership: 'settled', outcome: 'succeeded' })
const shell = childView({
id: 'child-2',
providerId: 'task-2',
kind: 'command',
parentChildWorkId: 'child-1'
})
views.current = () => [owner, shell]
feed.publish(SESSION)
expect(lastSummary(events)?.children).toEqual([owner, shell])
})
it("keeps a Claude child's own id, and a Codex subagent's the id its row always carried", async () => {
@@ -285,9 +304,7 @@ describe('structured status summary child records', () => {
it('stops listing children once the row leaves the sink, even with no close', async () => {
const { feed, events, views } = await feedWithChildren()
views.current = () => [
childView({ membership: 'settled', state: 'done', outcome: 'succeeded' })
]
views.current = () => [childView()]
feed.publish(SESSION)
expect(lastSummary(events)?.children).toHaveLength(1)
feed.forget(SESSION)
@@ -218,12 +218,13 @@ export class StructuredAgentSessionStatusFeed {
}
}
/** A finished child stays listed, with its outcome, until the session's own next turn begins,
/** A finished child's record stays, with its outcome, until the session's own next turn begins,
* unless it still owns live work. The provider ending the session removes nothing: children
* still live settle `unknown`, and settled ones stay. The one earlier death is the host letting
* go of the session, whose row takes every child record with it (see `forget`). Nothing caps how
* many settle within a turn, and a command never settles: its record goes when it stops. The
* summary and the strip both re-read the store, so each step removes a child from both. */
* many records a turn keeps, and a command never settles: its record goes when it stops. What
* each surface lists of them is picked on every read (`structured-agent-session-child-work-
* selection`): the sidebar only running children, the strip running then newest finished. */
private retireSettledChildrenOnNewTurn(
sessionId: string,
session: StatusFeedSession,
@@ -1,6 +1,7 @@
// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's
// canonical store, and the status summary every session list reads and the chat strip's channel
// both carry the same child records from it, while the parent row is folded from them too.
// canonical store; the status summary every session list reads carries the running children, the
// chat strip's channel carries those and the finished ones, and the parent row is folded from the
// same records.
//
// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the
// record producer read the same child executions, so agreeing with it could not catch a defect in
@@ -91,7 +92,7 @@ describe('the chat strip and the session list read the same host child records',
await rm(root, { recursive: true, force: true })
})
it('carries one record per child to both, and folds the parent row from them', async () => {
it('carries running children to both and finished ones to the strip, and folds the parent row from them', async () => {
const connections: CodexAppServerConnectionHandlers[] = []
const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
connections.push(handlers)
@@ -293,9 +294,10 @@ describe('the chat strip and the session list read the same host child records',
exitCode: 0
})
expect(parentRow()).toMatchObject({ state: 'done' })
// The finished child stays listed, with how it ended, until the session's next turn; the dev
// server's record went when its process exited.
both([
// The sidebar lists running children only; the finished child stays in the strip, with how it
// ended, until the session's next turn. The dev server's record went when its process exited.
expect(summaries.at(-1)).not.toHaveProperty('children')
expect(stripRows()).toEqual([
{
kind: 'agent',
description: 'review',
@@ -310,12 +312,13 @@ describe('the chat strip and the session list read the same host child records',
expect(strip.at(-1)).toBeNull()
// The reviewer's next run is still going when the provider exits: it settles with an outcome
// nobody reported, and both keep listing it.
// nobody reported, and leaves the sidebar while the strip keeps listing it.
await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r2', status: 'inProgress' } })
both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }])
connections[0]?.onExit?.(new Error('scripted provider exit'))
await settle()
both([
expect(summaries.at(-1)).not.toHaveProperty('children')
expect(stripRows()).toEqual([
{
kind: 'agent',
description: 'review',
@@ -325,7 +328,8 @@ describe('the chat strip and the session list read the same host child records',
}
])
// Closing its tab lets go of the session: its row, and every child record with it, leave both.
// Closing its tab lets go of the session: its row, and every child record with it, leave the
// store.
const subject = parentSubject(summaries)
await host.setSessionTabVisibility(SESSION, false)
await host.close(SESSION)
@@ -8,6 +8,7 @@ import {
import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus'
@@ -283,13 +284,20 @@ const FULL_ROW_DETAIL: Record<string, { shows: string[]; hides?: string[] }> = {
'parked, still live': { shows: [] }
}
// The sidebar lists what the host's summary carries: running children only. A finished child is
// read on the strip, and leaves the sidebar.
describe('a child reads the same in the sidebar and the chat strip', () => {
it.each(SCENARIOS)('%s', (name, children, expected) => {
const [sidebar] = sidebarRows(parentWith(children))
const listed = structuredSidebarChildWork(children)
const [strip] = stripRows(children)
expect(sidebar).toEqual(expected)
expect(strip).toEqual(expected)
const full = fullRow(parentWith(children))
if (listed.length === 0) {
expect(sidebarRows(parentWith(listed))).toEqual([])
return
}
const [sidebar] = sidebarRows(parentWith(listed))
expect(sidebar).toEqual(expected)
const full = fullRow(parentWith(listed))
expect(full.labels).toContain(expected.dot)
expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead)
for (const text of FULL_ROW_DETAIL[name].shows) {
@@ -300,15 +308,22 @@ describe('a child reads the same in the sidebar and the chat strip', () => {
}
})
it('names an unlabeled child by the same state on every surface', () => {
it('names an unlabeled finished child by its state on the strip, and leaves it off the sidebar', () => {
const children = [
settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' })
]
const [sidebar] = sidebarRows(parentWith(children))
const [strip] = stripRows(children)
expect(sidebar.lead).toBe('Failed')
expect(strip.lead).toBe('Failed')
expect(fullRow(parentWith(children)).text).toContain('Failed')
expect(sidebarRows(parentWith(structuredSidebarChildWork(children)))).toEqual([])
})
it('names an unlabeled running child by the same state on every surface', () => {
const children = [view('child', { description: undefined, agentType: undefined })]
const [sidebar] = sidebarRows(parentWith(structuredSidebarChildWork(children)))
const [strip] = stripRows(children)
expect(sidebar.lead).toBe('Working')
expect(strip.lead).toBe('Working')
expect(fullRow(parentWith(children)).text).toContain('Working')
})
it('shows the monitoring icon on the full sidebar row too', () => {
@@ -50,28 +50,28 @@ function renderCompactAgentRow(props: React.ComponentProps<typeof CompactAgentRo
}
describe('worktree card agent summary', () => {
it('keeps a failed child in the groups and the counts', () => {
const failed: DashboardAgentRowData = {
// The sidebar lists running children only, so a child row it counts is one that runs.
it("counts a running child by its own row's state", () => {
const running: DashboardAgentRowData = {
...monitoringAgent(),
paneKey: 'tab-1:leaf-1\u0000subagent:child',
rowSource: 'subagent',
state: 'blocked',
state: 'working',
childRow: {
id: 'child',
kind: 'agent',
displayState: 'failed',
displayState: 'waiting',
name: 'Fuzz the tokenizer',
detail: null,
firstObservedAt: 1,
recencyAt: 1,
canStop: false,
settled: true,
settled: false,
owned: []
}
}
expect(getAgentDotState(failed)).toBe('failed')
expect(buildSummaryAgentGroups([failed]).map((group) => group.state)).toEqual(['failed'])
expect(summarizeAgents([failed], 'Agent')).toBe('Agent failed')
expect(getAgentDotState(running)).toBe('waiting')
expect(buildSummaryAgentGroups([running]).map((group) => group.state)).toEqual(['waiting'])
})
it('presents passive working as monitoring', () => {
@@ -0,0 +1,65 @@
// Which of a session's child records each surface lists. The store keeps every record; these pick
// from them on each read, so nothing here is stored and nothing can disagree with the store.
//
// The sidebar (the status summary) lists running children only; a finished child stays in the
// chat's strip, which lists every running child and then the newest finished ones.
import type { AgentChildWorkView } from './agent-status-child-work-view'
/** The strip's row budget. Running children always show, even past it. */
export const STRUCTURED_STRIP_CHILD_WORK_LIMIT = 100
/** Settled children that still own live work: they read as monitoring, not finished, and keep
* that work's owner on screen. */
function settledOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set<string> {
const byId = new Map(views.map((view) => [view.id, view]))
const owners = new Set<string>()
for (const view of views) {
if (view.membership !== 'live') {
continue
}
const seen = new Set([view.id])
let owner = view.parentChildWorkId ? byId.get(view.parentChildWorkId) : undefined
while (owner && !seen.has(owner.id)) {
seen.add(owner.id)
if (owner.membership === 'settled') {
owners.add(owner.id)
}
owner = owner.parentChildWorkId ? byId.get(owner.parentChildWorkId) : undefined
}
}
return owners
}
function isRunning(view: AgentChildWorkView, owners: ReadonlySet<string>): boolean {
return view.membership === 'live' || owners.has(view.id)
}
/** The sidebar's children: the running ones, in store order. */
export function structuredSidebarChildWork(
views: readonly AgentChildWorkView[]
): AgentChildWorkView[] {
const owners = settledOwnersOfLiveWork(views)
return views.filter((view) => isRunning(view, owners))
}
/** The strip's children, in store order: every running child, then the newest finished ones, up to
* the row budget in all. */
export function structuredStripChildWork(
views: readonly AgentChildWorkView[]
): AgentChildWorkView[] {
if (views.length <= STRUCTURED_STRIP_CHILD_WORK_LIMIT) {
return [...views]
}
const owners = settledOwnersOfLiveWork(views)
const running = views.filter((view) => isRunning(view, owners))
const room = Math.max(0, STRUCTURED_STRIP_CHILD_WORK_LIMIT - running.length)
const newestFinished = new Set(
views
.filter((view) => !isRunning(view, owners))
.sort((a, b) => (b.settledAt ?? b.observedAt) - (a.settledAt ?? a.observedAt))
.slice(0, room)
.map((view) => view.id)
)
return views.filter((view) => isRunning(view, owners) || newestFinished.has(view.id))
}