diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 6464863f613..5ad0697456c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -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() @@ -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". diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts new file mode 100644 index 00000000000..ceb41dc4ad4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts index 3561ee077e6..0f4318e6b3c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -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`. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 8455fb5cdcb..1a69132e03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 339a66f2824..e69164c4406 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -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, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 4ba3c0149a4..ca209d6f966 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -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) diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index c8e3b4bd190..9d64e5363a0 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -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 = { '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', () => { diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 62a7d54c6d5..ef3751976ae 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -50,28 +50,28 @@ function renderCompactAgentRow(props: React.ComponentProps { - 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', () => { diff --git a/src/shared/structured-agent-session-child-work-selection.ts b/src/shared/structured-agent-session-child-work-selection.ts new file mode 100644 index 00000000000..6f1a9c8650e --- /dev/null +++ b/src/shared/structured-agent-session-child-work-selection.ts @@ -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 { + const byId = new Map(views.map((view) => [view.id, view])) + const owners = new Set() + 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): 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)) +}