diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 0f3605bee228..9013b07a6745 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -5,9 +5,9 @@ import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-wor import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, - projectAgentChildWorkViews -} from '../../shared/agent-status-child-work-view' + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' import { createAgentStatusStore } from '../../shared/agent-status-store' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 44d4463a30d7..fc011d48bb9e 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -12,9 +12,9 @@ import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liv import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, - projectAgentChildWorkViews -} from '../../shared/agent-status-child-work-view' + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' import { createAgentStatusStore } from '../../shared/agent-status-store' import { agentJournalLinkageFields } from '../../shared/agent-session-journal-producer' import type { diff --git a/src/renderer/src/components/AgentChildRowContent.tsx b/src/renderer/src/components/AgentChildRowContent.tsx new file mode 100644 index 000000000000..717b8438a0b8 --- /dev/null +++ b/src/renderer/src/components/AgentChildRowContent.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { AgentStateDot } from '@/components/AgentStateDot' +import type { StateIndicatorTooltipSide } from '@/components/StateIndicatorTooltip' +import { agentChildRowText } from './agent-child-row-text' + +type AgentChildRowContentProps = { + row: AgentChildRowModel + now: number + /** Surface tone for the name and the detail; the words themselves are the row's. */ + leadClassName: string + trailClassName: string + separator: string + /** Overrides the dot's hover label; null suppresses it for an existing tooltip. */ + dotTitle?: string | null + tooltipSide?: StateIndicatorTooltipSide + /** Name the whole line on hover, for surfaces that truncate it tightly. */ + lineTitle?: boolean +} + +/** + * A child row's state dot, name and detail: the one piece the sidebar's child rows and the chat + * strip's rows both render, so the same child reads the same on both. + */ +export const AgentChildRowContent = React.memo(function AgentChildRowContent({ + row, + now, + leadClassName, + trailClassName, + separator, + dotTitle, + tooltipSide, + lineTitle = false +}: AgentChildRowContentProps): React.JSX.Element { + const { lead, trail } = agentChildRowText(row, now) + return ( + <> + + + {lead} + {trail ? ( + + {separator} + {trail} + + ) : null} + + + ) +}) diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx new file mode 100644 index 000000000000..c8e3b4bd1900 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -0,0 +1,631 @@ +/** @vitest-environment happy-dom */ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentChildRowContextForParent, + type AgentChildRowContext +} from '../../../shared/agent-child-row-model' +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 type { TerminalTab } from '../../../shared/terminal-tab-types' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' +import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' +import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/background-task-roster' +import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' +import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' +import { TooltipProvider } from '@/components/ui/tooltip' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('@/components/sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +const NOW = 1_000_000 +const MINUTE = 60_000 + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: 'Audit the parser', + agentType: 'general-purpose', + state: 'working', + membership: 'live', + firstObservedAt: NOW - 5 * MINUTE, + observedAt: NOW - 2 * MINUTE, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...overrides + } +} + +function settled( + outcome: NonNullable, + overrides: Partial = {} +): AgentChildWorkView { + return view('child', { + state: 'done', + membership: 'settled', + outcome, + settledAt: NOW - 3 * MINUTE, + ...overrides + }) +} + +const OWNED_SHELL = view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' +}) + +function parentWith(children: AgentChildWorkView[], updatedAt = NOW): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'done', + prompt: 'parent prompt', + updatedAt, + stateStartedAt: NOW - 20 * MINUTE, + stateHistory: [], + children + } +} + +type RenderedRow = { dot: string; lead: string; trail: string } + +function readRow(root: Element, separator: string): RenderedRow { + const text = root.querySelector('span.truncate') + const [lead, trail] = [...(text?.children ?? [])].map((span) => span.textContent ?? '') + return { + dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '', + lead: lead ?? '', + trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '') + } +} + +function mount(markup: string): HTMLElement { + const container = document.createElement('div') + container.innerHTML = markup + return container +} + +function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRow[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + readRow( + mount( + renderToStaticMarkup( + + {}} /> + + ) + ), + ' - ' + ) + ) +} + +function stripRows( + children: AgentChildWorkView[] | undefined, + childRowContext?: AgentChildRowContext, + tasks: AgentSessionBackgroundTask[] = [] +): RenderedRow[] { + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + return [...root.querySelectorAll('li')] + .filter((row) => row.querySelector(':scope > span.truncate')) + .map((row) => readRow(row, ' · ')) +} + +/** The full sidebar row: every dot label it carries, and its whole text. */ +function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } { + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: true }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + return { + labels: [...root.querySelectorAll('[aria-label]')].map( + (element) => element.getAttribute('aria-label') ?? '' + ), + text: root.textContent ?? '' + } +} + +const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [ + [ + 'working, no known operation', + [view('child')], + { dot: 'Working', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'working with a tool', + [ + view('child', { + operation: { toolName: 'Read', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Read: src/parser.ts' } + ], + [ + 'running a shell in the foreground', + [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Bash: npm test' } + ], + [ + 'finished, while a shell it launched still runs', + [settled('succeeded', { lastMessage: 'All green' }), OWNED_SHELL], + { + dot: 'Monitoring background tasks', + lead: 'Monitoring background tasks', + trail: 'Audit the parser' + } + ], + [ + 'waiting on an approval', + [ + view('child', { + state: 'waiting', + operation: { toolName: 'Edit', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Waiting for input', lead: 'Audit the parser', trail: 'Edit: src/parser.ts' } + ], + [ + 'blocked', + [view('child', { state: 'blocked', lastMessage: 'Rate limited, retrying' })], + { dot: 'Blocked', lead: 'Audit the parser', trail: 'Rate limited, retrying' } + ], + [ + 'finished', + [settled('succeeded', { lastMessage: 'Found 3 call sites' })], + { dot: 'Done', lead: 'Audit the parser', trail: 'Found 3 call sites' } + ], + [ + 'failed', + [settled('failed', { lastMessage: 'Exit code 1' })], + { dot: 'Failed', lead: 'Audit the parser', trail: 'Exit code 1' } + ], + [ + 'cancelled', + [settled('cancelled')], + { dot: 'Interrupted', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'ended, outcome unknown', + [settled('unknown')], + { dot: 'Idle', lead: 'Audit the parser', trail: 'Ended' } + ], + [ + 'unverifiable', + [view('child', { state: 'unverifiable' })], + { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + ], + [ + 'parked, still live', + [view('child', { state: 'idle' })], + { dot: 'Idle', lead: 'Audit the parser', trail: 'general-purpose' } + ] +] + +/** What the full sidebar row, the CLI row's own layout, shows of the same detail. */ +const FULL_ROW_DETAIL: Record = { + 'working, no known operation': { shows: [] }, + 'working with a tool': { shows: ['Read', 'src/parser.ts'] }, + 'running a shell in the foreground': { shows: ['Bash', 'npm test'] }, + 'finished, while a shell it launched still runs': { shows: [], hides: ['All green'] }, + 'waiting on an approval': { shows: ['Edit', 'src/parser.ts'] }, + blocked: { shows: ['Rate limited, retrying'] }, + finished: { shows: ['Found 3 call sites'] }, + failed: { shows: ['Exit code 1'] }, + cancelled: { shows: [] }, + 'ended, outcome unknown': { shows: ['Ended'] }, + unverifiable: { shows: ['No update in 2m'] }, + 'parked, still live': { shows: [] } +} + +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 [strip] = stripRows(children) + expect(sidebar).toEqual(expected) + expect(strip).toEqual(expected) + const full = fullRow(parentWith(children)) + 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) { + expect(full.text).toContain(text) + } + for (const text of FULL_ROW_DETAIL[name].hides ?? []) { + expect(full.text).not.toContain(text) + } + }) + + it('names an unlabeled child by the same state on every surface', () => { + 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') + }) + + it('shows the monitoring icon on the full sidebar row too', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded'), OWNED_SHELL]), + tab, + parentIsFresh: true + }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(root.querySelector('[aria-label]')?.getAttribute('aria-label')).toBe( + 'Monitoring background tasks' + ) + // The finished child's last tool line is stale; neither row names it. + expect(root.textContent).not.toContain('Bash') + }) + + it('never shows a monitoring child tool text on either surface', () => { + const children = [ + settled('succeeded', { lastMessage: 'Bash: npm test finished' }), + { ...OWNED_SHELL, description: 'tail -f server.log' } + ] + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + for (const row of [sidebar, strip]) { + expect(row.dot).toBe('Monitoring background tasks') + expect(`${row.lead} ${row.trail}`).not.toContain('npm test') + } + }) +}) + +describe('a child running a shell in its turn', () => { + // The host records the running shell both as the child's operation and as a live command it owns. + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm run dev', basis: 'open', observedAt: NOW } + }), + { + ...OWNED_SHELL, + state: 'working' as const, + stoppable: false, + firstObservedAt: NOW - MINUTE + } + ] + const childRow: RenderedRow = { + dot: 'Working', + lead: 'Audit the parser', + trail: 'Bash: npm run dev' + } + + it('reads as the child working its shell, with the shell nested beneath it in the strip', () => { + const groups = buildBackgroundTaskGroupsFromViews(children) + expect(groups.map((group) => group.kind)).toEqual(['agent']) + expect(groups[0].tasks.map((entry) => entry.row.owned.map((owned) => owned.id))).toEqual([ + ['shell'] + ]) + expect(stripRows(children)).toEqual([ + childRow, + { dot: 'Working', lead: 'npm run dev', trail: '' } + ]) + }) + + it('shows the sidebar only the child, saying what the strip says of it', () => { + expect(sidebarRows(parentWith(children))).toEqual([childRow]) + }) +}) + +describe('one child reads the same from every shape a host publishes', () => { + it('names and details it identically from views, the subagents snapshot and the task roster', () => { + // A placeholder description falls through to the child's real label on every path. + const children = [view('child', { description: 'task', agentType: 'Explore' })] + const snapshotParent: AgentStatusEntry = { + ...parentWith([]), + children: undefined, + subagents: [ + { + id: 'child', + state: 'working', + startedAt: NOW - 5 * MINUTE, + description: 'task', + agentType: 'Explore' + } + ] + } + const roster: AgentSessionBackgroundTask[] = [ + { id: 'child', kind: 'agent', description: 'task', name: 'Explore', state: 'working' } + ] + const expected: RenderedRow = { dot: 'Working', lead: 'Explore', trail: '' } + expect(sidebarRows(parentWith(children))).toEqual([expected]) + expect(sidebarRows(snapshotParent)).toEqual([expected]) + expect(stripRows(children)).toEqual([expected]) + expect(stripRows(undefined, undefined, roster)).toEqual([expected]) + }) +}) + +describe('a mirrored parent and its children read one silence', () => { + const SKEW = 20 * MINUTE + // The host's clock runs 20 minutes ahead; this machine received its last word 3 minutes ago. + function mirroredParent(overrides: Partial): AgentStatusEntry { + return { + ...parentWith([]), + state: 'working', + updatedAt: NOW - 3 * MINUTE + SKEW, + mirroredEvidenceReceivedAt: NOW - 3 * MINUTE, + ...overrides + } + } + + it('times a snapshot child on the receipt clock the parent decays on', () => { + const parent = mirroredParent({ + children: undefined, + subagents: [ + { id: 'child', state: 'working', startedAt: NOW - 9 * MINUTE, description: 'Audit' } + ] + }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 3m') + }) + + it('times a view child by its own host-clock age, never across machines', () => { + const children = [view('child', { observedAt: NOW - 5 * MINUTE + SKEW })] + const parent = mirroredParent({ children }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 5m') + expect(stripRows(children, agentChildRowContextForParent(parent, false))[0].trail).toBe( + 'No update in 5m' + ) + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + const full = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(full.textContent).toContain('No update in 5m') + }) +}) + +describe('a lost or stale parent reads the same on both surfaces', () => { + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ] + const lost: RenderedRow = { + dot: 'No recent update', + lead: 'Audit the parser', + trail: 'No update in 2m' + } + + it('when the transport to the host is lost', () => { + const parent = { ...parentWith(children), subagentObservation: 'unverifiable' as const } + const [sidebar] = sidebarRows(parent) + const [strip] = stripRows(children, agentChildRowContextForParent(parent, true)) + expect(sidebar).toEqual(lost) + expect(strip).toEqual(lost) + }) + + it('when the parent row has gone stale', () => { + const parent = parentWith([settled('succeeded'), OWNED_SHELL], NOW - 40 * MINUTE) + const [sidebar] = sidebarRows(parent, false) + const [strip] = stripRows(parent.children ?? [], agentChildRowContextForParent(parent, false)) + const stale = { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + expect(sidebar).toEqual(stale) + expect(strip).toEqual(stale) + }) + + it('without a context the strip reports what the host last said', () => { + expect(stripRows(children)[0].dot).toBe('Working') + }) +}) + +describe('sibling child rows keep their own clocks', () => { + it('reads each sibling from its own evidence, not the parent clock', () => { + const parent = parentWith( + [ + view('busy', { description: 'Busy child', observedAt: NOW - 30_000 }), + view('quiet', { + description: 'Quiet child', + firstObservedAt: NOW - 15 * MINUTE, + observedAt: NOW - 10 * MINUTE + }) + ], + NOW - 40 * MINUTE + ) + const rows = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + expect(rows.map((row) => row.entry.evidenceObservedAt)).toEqual([ + NOW - 30_000, + NOW - 10 * MINUTE + ]) + expect(rows.map((row) => row.startedAt)).toEqual([NOW - 5 * MINUTE, NOW - 15 * MINUTE]) + expect(sidebarRows(parent, false).map((row) => row.trail)).toEqual([ + 'No update in 0m', + 'No update in 10m' + ]) + }) + + it('times a settled child from when it ended in the sidebar, and freezes its run in the strip', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded')]), + tab, + parentIsFresh: true + }) + const text = mount( + renderToStaticMarkup( + + {}} /> + + ) + ).textContent + expect(text?.endsWith('3m')).toBe(true) + const strip = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + // Ran from 5m ago until it settled 3m ago; a finished row never ticks. + expect(strip.querySelector('li')?.textContent).toMatch(/2m 0s$/) + }) +}) + +describe('the chat strip from views', () => { + it('nests a child-owned shell under its owner and stops by the provider id', () => { + const children = [ + view('child', { description: 'Dev server owner' }), + view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' + }), + view('main-shell', { + kind: 'command', + description: 'tail -f log', + agentType: undefined, + state: 'monitoring' + }) + ] + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + const groups = [...root.querySelectorAll('ul[aria-label]')].map((list) => + list.getAttribute('aria-label') + ) + expect(groups).toEqual(['Agents', 'Shell']) + const nested = root.querySelector('ul[aria-label="Agents"] ul') + expect(nested?.textContent).toContain('npm run dev') + expect(root.querySelector('ul[aria-label="Shell"]')?.textContent).not.toContain('npm run dev') + const stopShell = root.querySelector('button[aria-label="Stop npm run dev"]') + expect(stopShell?.hasAttribute('disabled')).toBe(true) + }) +}) + +describe('one lifecycle word for a child, on the sidebar row and the strip header', () => { + it.each<[string, AgentChildWorkView[], string, string]>([ + ['failed', [settled('failed')], 'blocked', 'blocked'], + ['cancelled', [settled('cancelled')], 'idle', 'idle'], + ['ended, outcome unknown', [settled('unknown')], 'idle', 'idle'], + ['finished', [settled('succeeded')], 'done', 'done'], + ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'], + ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting'] + ])('%s', (_name, children, sidebarState, headerState) => { + const [sidebar] = buildSubagentChildRows({ + parentEntry: parentWith(children), + tab, + parentIsFresh: true + }) + const [group] = buildBackgroundTaskGroupsFromViews(children) + // A CLI row carries monitoring as `working` plus its working mode; every other word is shared. + expect(sidebar.state).toBe(sidebarState) + expect(group.tasks[0].state).toBe(headerState) + }) +}) diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts new file mode 100644 index 000000000000..96ef7d1d5c72 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-text.ts @@ -0,0 +1,69 @@ +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import { agentStateLabel } from '@/components/AgentStateDot' +import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster' +import { translate } from '@/i18n/i18n' +import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state' +import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview' + +export type AgentChildRowText = { + /** Leads the line; kept when the row truncates. */ + lead: string + /** Follows the separator; '' when there is nothing more to say. */ + trail: string +} + +/** How long this child has been silent, on the reader's clock the model measured it on. */ +export function agentChildRowNoUpdateLabel(row: AgentChildRowModel, now: number): string { + return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now) +} + +/** The words for a row's detail, reusing the phrasing a CLI agent row uses for the same fact. */ +export function agentChildRowDetailText(row: AgentChildRowModel, now: number): string { + const detail = row.detail + if (!detail) { + return '' + } + switch (detail.kind) { + case 'operation': + return formatAgentToolPreview( + { toolName: detail.toolName, toolInput: detail.input }, + 'working' + ) + case 'monitoring': + return agentStateLabel('monitoring') + case 'message': + return detail.text + case 'ended': + return translate('components.agentChildRow.ended', 'Ended') + case 'no-update': + return agentChildRowNoUpdateLabel(row, now) + case 'role': + return formatAgentTypeLabel(detail.agentType) + case 'reason': + return backgroundTaskStateReason(detail.state) ?? '' + } +} + +/** The line beneath a full-width row: what the child said, or that it ended; '' otherwise. */ +export function agentChildRowMessageLine(row: AgentChildRowModel): string { + if (row.detail?.kind === 'message') { + return row.detail.text + } + return row.detail?.kind === 'ended' ? translate('components.agentChildRow.ended', 'Ended') : '' +} + +/** The row's name, or its state when the child reported none. */ +export function agentChildRowName(row: AgentChildRowModel): string { + return row.name.trim() || agentStateLabel(row.displayState) +} + +export function agentChildRowText(row: AgentChildRowModel, now: number): AgentChildRowText { + const name = agentChildRowName(row) + const detail = agentChildRowDetailText(row, now) + // Why: a monitoring row leads with its state so truncation keeps passive distinct from active. + if (row.displayState === 'monitoring' && detail) { + return { lead: detail, trail: detail === name ? '' : name } + } + return { lead: name, trail: detail } +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 1b64ec6af836..426fe8b57aac 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -9,12 +9,16 @@ import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingCo import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' -import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' +import { + agentChildRowMessageLine, + agentChildRowNoUpdateLabel +} from '@/components/agent-child-row-text' function formatTimeAgo(ts: number, now: number): string { const delta = now - ts @@ -24,6 +28,13 @@ function formatTimeAgo(ts: number, now: number): string { return `${formatCompactDuration(delta)} ago` } +// A child row's silence is the model's, on the clock its compact row and the strip read. +function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string { + return agent.childRow + ? agentChildRowNoUpdateLabel(agent.childRow, now) + : agentNoUpdateLabel(agent.entry, now) +} + function stateDotTooltipLabel( agent: DashboardAgentRowData, dotState: AgentDotState, @@ -34,9 +45,7 @@ function stateDotTooltipLabel( } // Why: report the observation, not a verdict on the agent — the elapsed gap is what // lets the user apply context Orca has no way to know (a long build, a slow download). - return dotState === 'unverifiable' - ? agentNoUpdateLabel(agent.entry, now) - : agentStateLabel(dotState) + return dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : agentStateLabel(dotState) } type Props = { @@ -127,7 +136,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) + const displayLabel = + prompt || + agentStateLabel( + agent.childRow?.displayState ?? asDotState(agent.state, agent.entry.workingMode) + ) const model = agent.entry.model?.trim() ?? '' const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' const isWorking = agent.state === 'working' && !isMonitoring @@ -137,9 +150,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' - const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const verdictDotState = agentVerdictDisplayMark(agent.entry) - const isInterrupted = verdictDotState === 'interrupted' + // Why: a child row's message line is the model's, so a child that ended without an outcome says so. + const lastAssistantMessage = agent.childRow + ? agentChildRowMessageLine(agent.childRow) + : (agent.entry.lastAssistantMessage?.trim() ?? '') + const isInterrupted = agentVerdictDisplayMark(agent.entry) === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,12 +167,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a // failure does so even while subagents still run. - const dotState: AgentDotState = - verdictDotState ?? asDotState(agent.state, agent.entry.workingMode) + const dotState: AgentDotState = agentRowDisplayDotState(agent) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. - const noUpdateLabel = dotState === 'unverifiable' ? agentNoUpdateLabel(agent.entry, now) : null + const noUpdateLabel = dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : null // Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 28d7a2572bbb..d176ae0a879b 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -12,11 +12,11 @@ import type { DashboardAgentRow } from './useDashboardData' * agent reads "N since it finished", an active one falls through to its start. */ export function lastEnteredDoneAt( - agent: Pick + agent: Pick ): number | null { - // Why: a subagent's synthetic entry may say done while its row is idle or unverifiable. - if (agent.rowSource === 'subagent' && agent.state !== 'done') { - return null + // Why: a subagent has no turns; it ended when its host settled it, if it has. + if (agent.rowSource === 'subagent') { + return agent.childRow?.settledAt ?? null } const entry = agent.entry // Why: same primitive Smart Sort ranks on, so the displayed age and Done eligibility share a clock. diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 02d4010edcdf..7ca391ef2346 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -1,6 +1,7 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { AgentRowState } from '@/lib/agent-row-decay-state' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AgentChildRowModel } from '../../../../shared/agent-child-row-model' export type DashboardAgentRow = { /** Row identity. For 'subagent' rows this is a synthetic key (the child has @@ -18,6 +19,8 @@ export type DashboardAgentRow = { * stateHistory entry, falling back to updatedAt when no history exists yet. * Used to sort agents by when they started. */ startedAt: number + /** Subagent rows only: what the row shows, decided once for every surface. */ + childRow?: AgentChildRowModel lineage?: { depth: 0 | 1 parentPaneKey?: string diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index 365be31cc476..e37bfa0f9dad 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -335,3 +335,47 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => { unmount() expect(vi.getTimerCount()).toBe(0) }) + +it('lets the 1 Hz tick sleep while every row has settled, with each run frozen', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const committed = vi.fn() + const finished = (id: string, firstObservedAt: number, settledAt: number) => ({ + id, + providerId: `task-${id}`, + kind: 'agent' as const, + description: `child ${id}`, + state: 'done' as const, + membership: 'settled' as const, + outcome: 'succeeded' as const, + firstObservedAt, + observedAt: settledAt, + settledAt, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 } + }) + render( + + {}} + isVisible + tasks={[]} + settledTasks={[]} + childViews={[finished('a', 400_000, 520_000), finished('b', 700_000, 760_000)]} + indicatorActive + supportsTaskStop + supportsStopAll + stoppingTaskIds={new Set()} + stoppingAll={false} + onStop={() => {}} + /> + + ) + const rows = screen.getAllByRole('listitem').map((row) => row.textContent) + expect(rows).toEqual(['child a · Agent2m 0s', 'child b · Agent1m 0s']) + committed.mockClear() + act(() => vi.advanceTimersByTime(5_000)) + expect(committed).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index de9ba8c655c0..a0ff61a78c85 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -1,18 +1,25 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' -import { AgentStateDot } from '@/components/AgentStateDot' +import type { + AgentChildRowContext, + AgentChildRowModel +} from '../../../../shared/agent-child-row-model' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { AgentChildRowContent } from '@/components/AgentChildRowContent' +import { agentChildRowName } from '@/components/agent-child-row-text' import { Button } from '@/components/ui/button' import { useNow } from '@/hooks/use-now' import { translate } from '@/i18n/i18n' import { backgroundTasksHeaderContent } from './background-task-header-content' import { - backgroundTaskElapsedLabel, backgroundTaskGroupLabel, - backgroundTaskStateReason, + backgroundTaskRowElapsedLabel, + backgroundTaskRowTicks, buildBackgroundTaskGroups, + buildBackgroundTaskGroupsFromViews, formatBackgroundTaskTokens, - type BackgroundRosterTask + type BackgroundTaskGroup } from './background-task-roster' /** Below this strip width (border-box, live root font size) the header drops @@ -64,78 +71,95 @@ function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean) return dimmed ? `${tone}/40` : tone } -/** Absent means stoppable: a host predating the field published only rows its - * stop could act on, so reading absence as "not stoppable" would hide a - * working control. Only an explicit `false` withholds the button — Claude - * marks its in-turn foreground rows that way, and a Stop on one of those - * resolves to an empty target list and silently reports nothing cancelled. */ -function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean { - return task.stoppable !== false -} - function BackgroundTaskRow(props: { - entry: BackgroundRosterTask + row: AgentChildRowModel now: number supportsTaskStop: boolean - stopping: boolean + stoppingTaskIds: ReadonlySet onStop: (taskId: string) => void }): React.JSX.Element { - const { entry, now } = props - const Icon = KIND_ICONS[entry.task.kind] - // Every attention state states its reason on the row, the same ones the collapsed - // header names; `unverifiable` ("no contact") must never be silently dropped. - const reason = backgroundTaskStateReason(entry.state) - // Settled rows keep their final usage but no elapsed — a still-growing clock - // on finished work would lie. + const { row, now } = props + const Icon = KIND_ICONS[row.kind] const meta = [ - entry.task.totalTokens !== undefined - ? formatBackgroundTaskTokens(entry.task.totalTokens) - : null, - entry.settled ? null : backgroundTaskElapsedLabel(entry.task, now) + row.totalTokens !== undefined ? formatBackgroundTaskTokens(row.totalTokens) : null, + backgroundTaskRowElapsedLabel(row, now) ] .filter((part): part is string => part !== null) .join(' · ') + // Only an explicit `false` withholds the button: a Stop on a row the host cannot target + // resolves to an empty list and silently reports nothing cancelled. + const stopId = + !row.settled && props.supportsTaskStop && row.canStop ? (row.providerId ?? null) : null return ( -
  • -
  • +
  • + {row.owned.length > 0 ? ( + // Work a child owns (its shell, a nested agent) reads beneath it, not in its kind's group. +
  • +
      + {row.owned.map((owned) => ( + + ))} +
    +
  • ) : null} - + ) } +function rowsTick(rows: readonly AgentChildRowModel[]): boolean { + return rows.some((row) => backgroundTaskRowTicks(row) || rowsTick(row.owned)) +} + export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] settledTasks: readonly AgentSessionBackgroundTask[] + /** The host's child views, when it publishes them; rows then read these instead of the tasks. */ + childViews?: readonly AgentChildWorkView[] + /** The session's parent-row context for those views, the one its sidebar rows read; without + * it every live claim stands as reported. */ + childRowContext?: AgentChildRowContext supportsTaskStop: boolean /** False when the provider exposes no honest stop at all; the fallback * control is hidden rather than offering a button that cannot act. */ @@ -160,16 +184,18 @@ export function NativeChatBackgroundTasksStatus(props: { const stripRef = useRef(null) const narrow = useNarrowStrip(stripRef) // The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster. - const groups = useMemo( - () => buildBackgroundTaskGroups(props.tasks, props.settledTasks), - [props.tasks, props.settledTasks] + const groups: BackgroundTaskGroup[] = useMemo( + () => + props.childViews !== undefined + ? buildBackgroundTaskGroupsFromViews(props.childViews, props.childRowContext) + : buildBackgroundTaskGroups(props.tasks, props.settledTasks), + [props.childViews, props.childRowContext, props.tasks, props.settledTasks] ) const singleLiveCommand = groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1 - const hasElapsed = groups.some((group) => - group.tasks.some((entry) => !entry.settled && (entry.task.startedAt ?? 0) > 0) - ) - const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand)) + // Settled rows are frozen, so a strip of only finished work never wakes the 1 Hz tick. + const ticks = groups.some((group) => rowsTick(group.tasks.map((entry) => entry.row))) + const now = useNow(1_000, props.isVisible && ticks && (expanded || singleLiveCommand)) const header = backgroundTasksHeaderContent(groups, { narrow, now }) const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}` return ( @@ -250,11 +276,11 @@ export function NativeChatBackgroundTasksStatus(props: { > {group.tasks.map((entry) => ( ))} diff --git a/src/renderer/src/components/native-chat/background-task-header-content.ts b/src/renderer/src/components/native-chat/background-task-header-content.ts index 0ec1c765e1f8..acbd97cb5431 100644 --- a/src/renderer/src/components/native-chat/background-task-header-content.ts +++ b/src/renderer/src/components/native-chat/background-task-header-content.ts @@ -166,8 +166,8 @@ export function backgroundTasksHeaderContent( : kindCountLabel(group.kind, 1) // A still-growing clock on finished work would lie, exactly as on the row. const elapsed = - group.kind === 'command' && !entry.settled - ? backgroundTaskElapsedLabel(entry.task, options.now) + group.kind === 'command' && !entry.row.settled + ? backgroundTaskElapsedLabel(entry.row.firstObservedAt, options.now) : null return { segments: [{ text: subject, kind: group.kind }], diff --git a/src/renderer/src/components/native-chat/background-task-roster.test.ts b/src/renderer/src/components/native-chat/background-task-roster.test.ts index eb2232056a7b..f4159632c692 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.test.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.test.ts @@ -172,8 +172,8 @@ describe('buildBackgroundTaskGroups', () => { [agent('settled', { state: 'done', startedAt: 0 })] ) expect(built.map((group) => group.kind)).toEqual(['agent', 'monitor']) - expect(built[0].tasks.map((entry) => entry.task.id)).toEqual(['settled', 'early', 'late']) - expect(built[0].tasks[0].settled).toBe(true) + expect(built[0].tasks.map((entry) => entry.row.id)).toEqual(['settled', 'early', 'late']) + expect(built[0].tasks[0].row.settled).toBe(true) }) it('defaults the state slot so a stateless row still reads as work', () => { @@ -234,9 +234,19 @@ describe('resumed tasks from mixed-version hosts', () => { [live, ...shells], [settled, agent('sibling', { state: 'done' })] ) - expect( - groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id) - ).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }]) + const owners = groups + .flatMap((group) => group.tasks) + .filter((entry) => entry.row.id === live.id) + expect(owners).toHaveLength(1) + expect(owners[0]).toMatchObject({ + row: { + settled: false, + displayState: 'working', + name: 'Background agent', + totalTokens: 20000 + }, + state: 'working' + }) expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([ { text: '2 agents', kind: 'agent' }, { text: '4 shells', kind: 'command' } diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts index 4b1c290e9444..7b62bb1f57c8 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.ts @@ -6,6 +6,15 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from '../../../../shared/agent-session-wire' +import { + buildAgentChildRowModels, + buildLegacyTaskRowModels, + usableAgentChildLabel, + type AgentChildRowContext, + type AgentChildRowModel +} from '../../../../shared/agent-child-row-model' +import { agentChildRunStateFor } from '../../../../shared/agent-status-child-work-display' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status' import { translate } from '@/i18n/i18n' @@ -13,10 +22,10 @@ type TaskKind = AgentSessionBackgroundTask['kind'] type RunState = AgentSessionBackgroundTaskRunState export type BackgroundRosterTask = { - task: AgentSessionBackgroundTask - settled: boolean + /** What the row shows, decided by the same model the sidebar's child rows use. */ + row: AgentChildRowModel + /** The row's display state in the header's vocabulary. */ state: RunState - name: string } export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[] } @@ -24,17 +33,6 @@ export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[ /** Fixed presentation order; groups render only when non-empty. */ const KIND_ORDER: readonly TaskKind[] = ['agent', 'command', 'monitor', 'workflow', 'unknown'] -/** Provider strings that carry no identity; a row falls through to its kind label. */ -const PLACEHOLDER_NAMES = new Set(['unknown', 'untitled', 'task', 'subagent']) - -function usableTaskText(value: string | undefined): string | null { - const trimmed = value?.trim() - if (!trimmed || PLACEHOLDER_NAMES.has(trimmed.toLowerCase())) { - return null - } - return trimmed -} - export function backgroundTaskKindLabel(kind: TaskKind): string { switch (kind) { case 'agent': @@ -50,56 +48,63 @@ export function backgroundTaskKindLabel(kind: TaskKind): string { } } -/** Display name: description → name → kind label. Empty-after-trim and - * placeholder values fall through, so a row always renders something. */ +/** The transcript row's name: description → name → kind label. Empty-after-trim and + * placeholder values fall through, so the row always renders something. */ export function resolveBackgroundTaskName(task: AgentSessionBackgroundTask): string { return ( - usableTaskText(task.description) ?? - usableTaskText(task.name) ?? + usableAgentChildLabel(task.description) ?? + usableAgentChildLabel(task.name) ?? backgroundTaskKindLabel(task.kind) ) } -function effectiveState(task: AgentSessionBackgroundTask, settled: boolean): RunState { - if (task.state) { - return task.state - } - if (settled) { - return 'done' - } - return task.kind === 'monitor' ? 'monitoring' : 'working' +/** Stable-sort first-seen then id, so a live update never reshuffles surviving rows. */ +function groupRosterEntries(entries: BackgroundRosterTask[]): BackgroundTaskGroup[] { + entries.sort((left, right) => { + const startDelta = left.row.firstObservedAt - right.row.firstObservedAt + return startDelta !== 0 ? startDelta : left.row.id < right.row.id ? -1 : 1 + }) + return KIND_ORDER.map((kind) => ({ + kind, + tasks: entries.filter((entry) => entry.row.kind === kind) + })).filter((group) => group.tasks.length > 0) } -/** Merge live and settled tasks into kind groups, stable-sorted first-seen - * (startedAt) then id, so a live update never reshuffles surviving rows. */ +function rosterEntries(rows: readonly AgentChildRowModel[]): BackgroundTaskGroup[] { + return groupRosterEntries( + rows.map((row) => ({ row, state: agentChildRunStateFor(row.displayState) })) + ) +} + +/** Kind groups from a host that publishes only the task roster, live and settled merged. */ export function buildBackgroundTaskGroups( tasks: readonly AgentSessionBackgroundTask[], settledTasks: readonly AgentSessionBackgroundTask[] ): BackgroundTaskGroup[] { - // Older hosts can retain a previous turn beside its resumed live task. - const owners = new Map() - for (const [roster, settled] of [ - [settledTasks, true], - [tasks, false] - ] as const) { - for (const task of roster) { - owners.set(task.id, { - task, - settled, - state: effectiveState(task, settled), - name: resolveBackgroundTaskName(task) - }) - } - } - const entries = [...owners.values()] - entries.sort((left, right) => { - const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0) - return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1 - }) - return KIND_ORDER.map((kind) => ({ - kind, - tasks: entries.filter((entry) => entry.task.kind === kind) - })).filter((group) => group.tasks.length > 0) + // An old host's unlabeled row keeps the kind label it always showed; a view row reads its state. + return rosterEntries( + buildLegacyTaskRowModels(tasks, settledTasks).map((row) => + row.name ? row : { ...row, name: backgroundTaskKindLabel(row.kind) } + ) + ) +} + +// Until a caller passes the session's parent-row context, every live claim stands as reported. +const REPORTED_ROW_CONTEXT: AgentChildRowContext = { + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 0, + hostClockOffsetMs: 0 +} + +/** Kind groups from the host's child views: the main agent's work at the top, each child's own + * work nested beneath it rather than counted again in its kind's group. Pass the context the + * sidebar builds for the same parent (`agentChildRowContextForParent`) so both read one verdict. */ +export function buildBackgroundTaskGroupsFromViews( + views: readonly AgentChildWorkView[], + context: AgentChildRowContext = REPORTED_ROW_CONTEXT +): BackgroundTaskGroup[] { + return rosterEntries(buildAgentChildRowModels(views, context)) } export function backgroundTaskStateWord(state: RunState): string { @@ -155,14 +160,26 @@ export function formatBackgroundTaskTokens(totalTokens: number): string { : `${tokenScaleText(Math.round(totalTokens / 100_000) / 10)}m` } -export function backgroundTaskElapsedLabel( - task: AgentSessionBackgroundTask, - now: number -): string | null { - if (task.startedAt === undefined || task.startedAt <= 0) { +export function backgroundTaskElapsedLabel(startedAt: number, now: number): string | null { + if (startedAt <= 0) { return null } - return formatNativeChatDuration((now - task.startedAt) / 1000) + return formatNativeChatDuration((now - startedAt) / 1000) +} + +/** Whether a row's elapsed time moves: only live work's does. */ +export function backgroundTaskRowTicks(row: AgentChildRowModel): boolean { + return !row.settled && row.firstObservedAt > 0 +} + +/** A live row's clock runs; a settled row's stops where it settled, so finished work never ticks. */ +export function backgroundTaskRowElapsedLabel(row: AgentChildRowModel, now: number): string | null { + if (!row.settled) { + return backgroundTaskElapsedLabel(row.firstObservedAt, now) + } + return row.settledAt !== undefined + ? backgroundTaskElapsedLabel(row.firstObservedAt, row.settledAt) + : null } export function backgroundTaskGroupLabel(kind: TaskKind): string { diff --git a/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap new file mode 100644 index 000000000000..d1c70a5fee23 --- /dev/null +++ b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap @@ -0,0 +1,46 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`chat strip rows from a legacy background-task roster > render exactly as before 1`] = `"

    Agents

    • Failed child · failed
    • Background agent · no contact
    • Cancelled child
    • Approve edits · needs approval
    • Summarize logs900
    • Review the parser18.2k · 10m 0s
    • Explore · failed8m 20s
    • Old host child6m 40s

    Shell

    • npm run dev11m 40s

    Monitors

    • tail -f server.log
    "`; + +exports[`chat strip rows from a legacy background-task roster > render exactly as before 2`] = `"

    Agents

    • Failed child · failed
    • Background agent · no contact
    • Cancelled child
    • Approve edits · needs approval
    • Summarize logs900
    • Review the parser18.2k · 10m 0s
    • Explore · failed8m 20s
    • Old host child6m 40s

    Shell

    • npm run dev11m 40s

    Monitors

    • tail -f server.log
    "`; + +exports[`sidebar child rows from a legacy subagents snapshot > render a stale parent and a lost transport exactly as before 1`] = ` +[ + "
    Review the parser - No update in 5mclaude-sonnet10m
    ", + "
    Explore - No update in 5m8m
    ", + "
    No recent update - No update in 5m16m
    ", + "
    Approve edits - No update in 5m6m
    ", + "
    Review the parser - No update in 5mclaude-sonnet10m
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the compact rows exactly as before 1`] = ` +[ + "
    Review the parser - general-purposeclaude-sonnet10m
    ", + "
    Explore8m
    ", + "
    Working - Agent16m
    ", + "
    Approve edits - Agent6m
    ", + "
    Rate limited - Agent6m
    ", + "
    Teammate - writer6m
    ", + "
    Lost child - No update in 5m6m
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 1`] = ` +[ + "
    Review the parserclaude-sonnet
    ", + "
    Explore
    ", + "
    Working
    ", + "
    Approve edits
    ", + "
    Rate limited
    ", + "
    Teammate
    ", + "
    Lost child
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 2`] = ` +[ + "
    Review the parserclaude-sonnet
    ", + "
    Explore
    ", +] +`; diff --git a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx new file mode 100644 index 000000000000..0be9f5ae5469 --- /dev/null +++ b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx @@ -0,0 +1,237 @@ +/** @vitest-environment happy-dom */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' +import type { AgentStatusEntry, AgentSubagentSnapshot } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' +import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' +import { TooltipProvider } from '@/components/ui/tooltip' +import { CompactAgentRow } from './worktree-card-compact-agent-row' +import { buildSubagentChildRows } from './worktree-subagent-child-rows' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +// Captured on the unmodified renderer: a host that sends only today's legacy shapes (an old host, +// or any CLI pane) must keep rendering exactly these rows. + +const NOW = 1_000_000 + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function parentWith( + subagents: AgentSubagentSnapshot[], + overrides: Partial = {} +): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'working', + prompt: 'parent prompt', + updatedAt: 700_000, + stateStartedAt: 10, + stateHistory: [], + subagents, + ...overrides + } +} + +const SUBAGENTS: AgentSubagentSnapshot[] = [ + { + id: 'described', + state: 'working', + startedAt: 400_000, + agentType: 'general-purpose', + model: 'claude-sonnet', + description: 'Review the parser' + }, + { id: 'typed-only', state: 'working', startedAt: 500_000, agentType: 'Explore' }, + { id: 'anonymous', state: 'working', startedAt: 0 }, + { id: 'asking', state: 'waiting', startedAt: 600_000, description: 'Approve edits' }, + { id: 'stuck', state: 'blocked', startedAt: 610_000, description: 'Rate limited' }, + { id: 'parked', state: 'idle', startedAt: 620_000, description: 'Teammate', agentType: 'writer' }, + { id: 'quiet', state: 'unverifiable', startedAt: 630_000, description: 'Lost child' } +] + +function normalize(markup: string): string { + return markup.replaceAll('', '') +} + +function compactMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + normalize( + renderToStaticMarkup( + + {}} /> + + ) + ) + ) +} + +function fullMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + normalize( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + hideLineageConnectors + /> + + ) + ) + ) +} + +function textOf(markup: string): string { + const container = document.createElement('div') + container.innerHTML = markup + return container.textContent ?? '' +} + +describe('sidebar child rows from a legacy subagents snapshot', () => { + it('render the compact rows exactly as before', () => { + const rows = compactMarkup(parentWith(SUBAGENTS), true) + expect(rows.map(textOf)).toEqual([ + 'Review the parser - general-purposeclaude-sonnet10m', + 'Explore8m', + 'Working - Agent16m', + 'Approve edits - Agent6m', + 'Rate limited - Agent6m', + 'Teammate - writer6m', + 'Lost child - No update in 5m6m' + ]) + expect(rows).toMatchSnapshot() + }) + + it('render a stale parent and a lost transport exactly as before', () => { + const stale = compactMarkup(parentWith(SUBAGENTS.slice(0, 4)), false) + const lost = compactMarkup( + parentWith(SUBAGENTS.slice(0, 1), { subagentObservation: 'unverifiable' }), + true + ) + expect([...stale, ...lost].map(textOf)).toEqual([ + 'Review the parser - No update in 5mclaude-sonnet10m', + 'Explore - No update in 5m8m', + 'No recent update - No update in 5m16m', + 'Approve edits - No update in 5m6m', + 'Review the parser - No update in 5mclaude-sonnet10m' + ]) + expect([...stale, ...lost]).toMatchSnapshot() + }) + + it('render the full rows exactly as before', () => { + expect(fullMarkup(parentWith(SUBAGENTS), true)).toMatchSnapshot() + expect(fullMarkup(parentWith(SUBAGENTS.slice(0, 2)), false)).toMatchSnapshot() + }) +}) + +const TASKS: AgentSessionBackgroundTask[] = [ + { + id: 'agent-working', + kind: 'agent', + description: 'Review the parser', + name: 'general-purpose', + state: 'working', + startedAt: 400_000, + totalTokens: 18_200 + }, + { id: 'agent-waiting', kind: 'agent', description: 'Approve edits', state: 'waiting' }, + { id: 'agent-blocked', kind: 'agent', name: 'Explore', state: 'blocked', startedAt: 500_000 }, + { id: 'agent-quiet', kind: 'agent', description: 'subagent', state: 'unverifiable' }, + { id: 'agent-stateless', kind: 'agent', description: 'Old host child', startedAt: 600_000 }, + { + id: 'command-1', + kind: 'command', + description: 'npm run dev', + startedAt: 300_000, + stoppable: false + }, + { id: 'monitor-1', kind: 'monitor', description: 'tail -f server.log' } +] + +const SETTLED_TASKS: AgentSessionBackgroundTask[] = [ + { + id: 'agent-done', + kind: 'agent', + description: 'Summarize logs', + state: 'done', + startedAt: 100_000, + totalTokens: 900 + }, + { id: 'agent-stopped', kind: 'agent', description: 'Cancelled child', state: 'idle' }, + { id: 'agent-failed', kind: 'agent', description: 'Failed child', state: 'blocked' } +] + +function stripMarkup(supportsTaskStop: boolean): string { + vi.useFakeTimers() + vi.setSystemTime(NOW) + try { + return normalize( + renderToStaticMarkup( + + {}} + onStop={() => {}} + /> + + ) + ) + } finally { + vi.useRealTimers() + } +} + +describe('chat strip rows from a legacy background-task roster', () => { + it('render exactly as before', () => { + const markup = stripMarkup(true) + const container = document.createElement('div') + container.innerHTML = markup + expect([...container.querySelectorAll('li')].map((row) => row.textContent)).toEqual([ + 'Failed child · failed', + 'Background agent · no contactStop', + 'Cancelled child', + 'Approve edits · needs approvalStop', + 'Summarize logs900', + 'Review the parser18.2k · 10m 0sStop', + 'Explore · failed8m 20sStop', + 'Old host child6m 40sStop', + 'npm run dev11m 40s', + 'tail -f server.logStop' + ]) + expect(markup).toMatchSnapshot() + expect(stripMarkup(false)).toMatchSnapshot() + }) +}) 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 7f1215c980a5..62a7d54c6d57 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 @@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row' -import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary' +import { + buildSummaryAgentGroups, + getAgentDotState, + summarizeAgents +} from './worktree-card-agent-summary' import { buildSubagentChildRows } from './worktree-subagent-child-rows' function monitoringAgent(): DashboardAgentRowData { @@ -46,6 +50,30 @@ function renderCompactAgentRow(props: React.ComponentProps { + it('keeps a failed child in the groups and the counts', () => { + const failed: DashboardAgentRowData = { + ...monitoringAgent(), + paneKey: 'tab-1:leaf-1\u0000subagent:child', + rowSource: 'subagent', + state: 'blocked', + childRow: { + id: 'child', + kind: 'agent', + displayState: 'failed', + name: 'Fuzz the tokenizer', + detail: null, + firstObservedAt: 1, + recencyAt: 1, + canStop: false, + settled: true, + owned: [] + } + } + expect(getAgentDotState(failed)).toBe('failed') + expect(buildSummaryAgentGroups([failed]).map((group) => group.state)).toEqual(['failed']) + expect(summarizeAgents([failed], 'Agent')).toBe('Agent failed') + }) + it('presents passive working as monitoring', () => { const agent = monitoringAgent() diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 4ce3f1ad4176..76fe81307e90 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -1,8 +1,7 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel } from '@/lib/agent-status' -import { agentRowDotState } from '@/lib/agent-row-dot-state' -import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' +import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' export type SummaryAgentGroup = { state: AgentDotState @@ -24,9 +23,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ ] export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { - return ( - agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) - ) + return agentRowDisplayDotState(agent) } export function formatSummaryStateLabel(state: AgentDotState): string { diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 094cd2e7db9d..7ee488341852 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentChildRowContent } from '@/components/AgentChildRowContent' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' @@ -183,41 +184,55 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ ) : null + // Why: the selected-row fill is strong enough to wash out the dimmed prompt/secondary text, so + // lift the lead toward full foreground when focused; an unvisited row stays bold either way. + const leadClassName = cn( + isUnvisited ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground/90', + isFocusedPane && !isUnvisited && 'text-foreground' + ) + const rowBody = ( <> - {/* Why: the row's actionable disabled reason must win on every hit area. */} - - {!hideIcon && ( - - - - )} - - {/* Why: the selected-row fill is strong enough to wash out the dimmed - prompt/secondary text, so lift both toward full foreground when focused. */} - + ) : ( + <> + {/* Why: the row's actionable disabled reason must win on every hit area. */} + + {!hideIcon && ( + + + )} - > - {leadingText} - - {trailingText && ( - - {' '} - - {trailingText} + + {leadingText} + {trailingText && ( + + {' '} + - {trailingText} + + )} - )} - + + )} {model && ( 0 ? row.firstObservedAt : parentEntry.stateStartedAt + const paneKey = subagentRowKey(parentEntry.paneKey, row.id) + const detail = row.detail + // The same fields a CLI agent row carries, so every reader of a CLI row reads a child the same way. + const entry: AgentStatusEntry = { + state: state === 'idle' || state === 'unverifiable' ? 'done' : state, + ...(row.displayState === 'monitoring' ? { workingMode: 'monitoring' as const } : {}), + prompt: row.name, + // The parent's delivery clock; the child's own evidence clock rides `evidenceObservedAt`. + updatedAt: parentEntry.updatedAt, + ...(row.observedAt !== undefined ? { evidenceObservedAt: row.observedAt } : {}), + stateStartedAt: startedAt, + agentType: row.agentType, + model: row.model, + ...(detail?.kind === 'operation' + ? { + toolName: detail.toolName, + ...(detail.input !== undefined ? { toolInput: detail.input } : {}) + } + : {}), + ...(detail?.kind === 'message' ? { lastAssistantMessage: detail.text } : {}), + paneKey, + worktreeId: parentEntry.worktreeId, + tabId: parentEntry.tabId, + stateHistory: [], + orchestration: { + taskId: `subagent:${row.id}`, + dispatchId: `subagent:${row.id}`, + displayName: row.name || undefined, + parentPaneKey: parentEntry.paneKey + } + } + return { + paneKey, + entry, + tab, + agentType: row.agentType ?? 'unknown', + rowSource: 'subagent', + state, + activationPaneKey: parentEntry.paneKey, + startedAt, + childRow: row + } +} + /** - * Derive indented child rows for the live in-process subagents/teammates a - * pane's agent has spawned (entry.subagents, reported via agent hooks). These - * children have no PTY or tab of their own: the rows reuse the parent's tab, - * activate the parent's pane, and link into the existing lineage tree through - * `orchestration.parentPaneKey`. + * Derive indented child rows for the subagents/teammates a pane's agent has + * spawned. These children have no PTY or tab of their own: the rows reuse the + * parent's tab, activate the parent's pane, and link into the existing lineage + * tree through `orchestration.parentPaneKey`. The host's child views win; a host + * that sends only the legacy `subagents` snapshot (an old host, any CLI pane) + * is read as before. */ export function buildSubagentChildRows(args: { parentEntry: AgentStatusEntry @@ -25,48 +95,14 @@ export function buildSubagentChildRows(args: { * states are equally unverifiable. */ parentIsFresh: boolean }): DashboardAgentRow[] { - const subagents = args.parentEntry.subagents - if (!subagents || subagents.length === 0) { - return [] - } - return subagents.map((subagent) => { - const freshness = resolveAgentChildWorkFreshness({ - state: subagent.state, - membership: 'live', - parentEvidenceFresh: args.parentIsFresh, - transportObservation: args.parentEntry.subagentObservation ?? 'live' - }) - const state = freshness === 'done' ? 'idle' : freshness === 'monitoring' ? 'working' : freshness - const activeState = state !== 'idle' && state !== 'unverifiable' ? state : undefined - const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt - const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) - const entry: AgentStatusEntry = { - state: activeState ?? 'done', - prompt: subagent.description ?? subagent.agentType ?? '', - updatedAt: args.parentEntry.updatedAt, - stateStartedAt: startedAt, - agentType: subagent.agentType, - model: subagent.model, - paneKey, - worktreeId: args.parentEntry.worktreeId, - tabId: args.parentEntry.tabId, - stateHistory: [], - orchestration: { - taskId: `subagent:${subagent.id}`, - dispatchId: `subagent:${subagent.id}`, - displayName: subagent.description, - parentPaneKey: args.parentEntry.paneKey - } - } - return { - paneKey, - entry, - tab: args.tab, - agentType: subagent.agentType ?? 'unknown', - rowSource: 'subagent' as const, - state, - activationPaneKey: args.parentEntry.paneKey, - startedAt - } - }) + const { parentEntry } = args + const context = agentChildRowContextForParent(parentEntry, args.parentIsFresh) + const rows = + parentEntry.children !== undefined + ? buildAgentChildRowModels(parentEntry.children, context) + : buildLegacyAgentChildRowModels(parentEntry.subagents ?? [], context) + // Shells and monitors show through their owner's dot; the sidebar lists agents. + return flattenAgentChildRowModels(rows) + .filter((row) => row.kind === 'agent') + .map((row) => childDashboardRow(row, parentEntry, args.tab)) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9351961c28ab..5db365466e6a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18197,6 +18197,9 @@ } } } + }, + "agentChildRow": { + "ended": "Ended" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e0e77e574ec9..79070fc23f5d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15171,6 +15171,9 @@ "copySessionIdSuccess": "ID de sesión copiado", "copySessionIdError": "No se pudo copiar el ID de sesión" } + }, + "agentChildRow": { + "ended": "Finalizado" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9a0f0d437c93..04e9bb284ab6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18169,6 +18169,9 @@ } } } + }, + "agentChildRow": { + "ended": "Terminé" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6ec0e9c65e7e..90f55805342d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "終了" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e5d5cac60fcc..dd0dbdfa8a76 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "종료됨" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d8a7ebf4561f..1514dd25697c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "已结束" } }, "dashboardPopout": { diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts index 79c0d7c198c5..bf2b7b1e5320 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -1,5 +1,7 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { AgentWorkingMode } from '../../../shared/agent-status-types' +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types' import type { AgentRowState } from './agent-row-decay-state' /** @@ -22,3 +24,20 @@ export function agentRowDotState( } return 'idle' } + +/** + * The dot an agent row renders: a child row's own, else the main agent's verdict (a stop or a + * failure, which a failure shows even while subagents still run), else its state. + */ +export function agentRowDisplayDotState(agent: { + state: AgentRowState + entry: Pick + childRow?: Pick +}): AgentDotState { + if (agent.childRow) { + return agent.childRow.displayState + } + return ( + agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) + ) +} diff --git a/src/shared/agent-child-row-model.test.ts b/src/shared/agent-child-row-model.test.ts new file mode 100644 index 000000000000..a99ef0a71520 --- /dev/null +++ b/src/shared/agent-child-row-model.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest' +import { + agentChildRowContextForParent, + buildAgentChildRowModels, + buildLegacyAgentChildRowModels, + buildLegacyTaskRowModels, + flattenAgentChildRowModels, + type AgentChildRowContext, + type AgentChildRowModel +} from './agent-child-row-model' +import type { AgentChildWorkView } from './agent-status-child-work-view' + +const FRESH: AgentChildRowContext = { + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 900, + hostClockOffsetMs: 0 +} +const STALE: AgentChildRowContext = { ...FRESH, parentEvidenceFresh: false } +const LOST: AgentChildRowContext = { ...FRESH, transportObservation: 'unverifiable' } + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: `child ${id}`, + agentType: 'general-purpose', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 500, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...overrides + } +} + +function settled( + id: string, + outcome: NonNullable, + overrides: Partial = {} +): AgentChildWorkView { + return view(id, { + state: 'done', + membership: 'settled', + outcome, + settledAt: 450, + ...overrides + }) +} + +function shell(id: string, owner: string, overrides: Partial = {}) { + return view(id, { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: owner, + ...overrides + }) +} + +function only(views: AgentChildWorkView[], context = FRESH): AgentChildRowModel { + const [row] = buildAgentChildRowModels(views, context) + return row +} + +describe('buildAgentChildRowModels: the display vocabulary', () => { + it.each<[string, AgentChildWorkView[], Pick]>([ + [ + 'working with no known operation names its role', + [view('a')], + { displayState: 'working', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'working with a foreground tool names the tool the way a CLI row does', + [view('a', { operation: { toolName: 'Read', basis: 'open', observedAt: 400 } })], + { displayState: 'working', detail: { kind: 'operation', toolName: 'Read' } } + ], + [ + 'a foreground shell reads as working with its command', + [ + view('a', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 400 } + }) + ], + { + displayState: 'working', + detail: { kind: 'operation', toolName: 'Bash', input: 'npm test' } + } + ], + [ + 'an idle child whose own shell runs reads monitoring', + [view('a', { state: 'idle' }), shell('s', 'a')], + { displayState: 'monitoring', detail: { kind: 'monitoring' } } + ], + [ + 'a finished child whose own shell runs reads monitoring', + [settled('a', 'succeeded', { lastMessage: 'All green' }), shell('s', 'a')], + { displayState: 'monitoring', detail: { kind: 'monitoring' } } + ], + [ + 'waiting names the tool the approval is for', + [ + view('a', { + state: 'waiting', + operation: { toolName: 'Edit', input: 'src/a.ts', basis: 'open', observedAt: 400 } + }) + ], + { + displayState: 'waiting', + detail: { kind: 'operation', toolName: 'Edit', input: 'src/a.ts' } + } + ], + [ + 'blocked says why', + [view('a', { state: 'blocked', lastMessage: 'Rate limited' })], + { displayState: 'blocked', detail: { kind: 'message', text: 'Rate limited' } } + ], + [ + 'succeeded reads done with its last message', + [settled('a', 'succeeded', { lastMessage: 'Found 3 call sites' })], + { displayState: 'done', detail: { kind: 'message', text: 'Found 3 call sites' } } + ], + [ + 'failed reads failed with its error', + [settled('a', 'failed', { lastMessage: 'Exit code 1' })], + { displayState: 'failed', detail: { kind: 'message', text: 'Exit code 1' } } + ], + [ + 'cancelled reads interrupted and adds no message', + [settled('a', 'cancelled', { lastMessage: 'half a thought' })], + { displayState: 'interrupted', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'an ending the lane cannot classify is neutral and says Ended', + [settled('a', 'unknown')], + { displayState: 'idle', detail: { kind: 'ended' } } + ], + [ + 'a parked live child names its role', + [view('a', { state: 'idle' })], + { displayState: 'idle', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'a host-reported unverifiable child reports its silence', + [view('a', { state: 'unverifiable' })], + { displayState: 'unverifiable', detail: { kind: 'no-update' } } + ] + ])('%s', (_name, views, expected) => { + expect(only(views)).toMatchObject(expected) + }) + + it('suppresses the tool line while monitoring, even when a stale operation arrives', () => { + const row = only([ + view('a', { + state: 'idle', + operation: { toolName: 'Bash', input: 'npm test', basis: 'reported', observedAt: 400 } + }), + shell('s', 'a') + ]) + expect(row.displayState).toBe('monitoring') + expect(row.detail).toEqual({ kind: 'monitoring' }) + }) + + it('makes every live claim unverifiable under a stale parent or a lost transport', () => { + for (const context of [STALE, LOST]) { + expect(only([view('a')], context)).toMatchObject({ + displayState: 'unverifiable', + detail: { kind: 'no-update' } + }) + // A finished child's monitoring is a claim about live work; its outcome is history. + expect(only([settled('a', 'succeeded'), shell('s', 'a')], context).displayState).toBe( + 'unverifiable' + ) + expect(only([settled('a', 'failed')], context).displayState).toBe('failed') + expect(only([view('a', { state: 'idle' })], context).displayState).toBe('idle') + } + }) + + it('gives sibling rows their own clocks, never the parent one', () => { + const [busy, quiet] = buildAgentChildRowModels( + [view('busy', { observedAt: 880 }), view('quiet', { firstObservedAt: 200, observedAt: 300 })], + STALE + ) + expect([busy.recencyAt, quiet.recencyAt]).toEqual([880, 300]) + expect([busy.observedAt, quiet.observedAt]).toEqual([880, 300]) + expect([busy.firstObservedAt, quiet.firstObservedAt]).toEqual([100, 200]) + }) + + it('nests owned work under its owner and keeps host order', () => { + const rows = buildAgentChildRowModels( + [ + view('a'), + shell('s', 'a', { state: 'working' }), + view('b'), + view('grandchild', { parentChildWorkId: 'a' }) + ], + FRESH + ) + expect(rows.map((row) => row.id)).toEqual(['a', 'b']) + expect(rows[0].owned.map((row) => row.id)).toEqual(['s', 'grandchild']) + expect(flattenAgentChildRowModels(rows).map((row) => row.id)).toEqual([ + 'a', + 's', + 'grandchild', + 'b' + ]) + // A shell names itself; only an agent's own tool line can go stale. + expect(rows[0].owned[0]).toMatchObject({ displayState: 'working', detail: null }) + }) + + it('names a row by its label and omits a role that restates it', () => { + expect( + only([view('a', { description: ' ', name: 'subagent', agentType: 'Explore' })]) + ).toMatchObject({ name: 'Explore', detail: null }) + expect(only([view('a', { description: undefined, agentType: undefined })])).toMatchObject({ + name: '', + detail: { kind: 'role', agentType: 'unknown' } + }) + }) + + it('offers a stop only for a live row the host can target', () => { + expect(only([view('a')]).canStop).toBe(true) + expect(only([view('a', { providerId: undefined })]).canStop).toBe(false) + expect(only([view('a', { stoppable: false })]).canStop).toBe(false) + expect(only([settled('a', 'succeeded')]).canStop).toBe(false) + }) +}) + +describe('buildLegacyAgentChildRowModels', () => { + it('reads a legacy snapshot as before: no clock of its own, no outcome, no operation', () => { + const rows = buildLegacyAgentChildRowModels( + [ + { id: 'w', state: 'working', startedAt: 10, description: 'Review', agentType: 'Explore' }, + { id: 'i', state: 'idle', startedAt: 20, agentType: 'writer' }, + { id: 'u', state: 'unverifiable', startedAt: 30 } + ], + FRESH + ) + expect(rows).toEqual([ + expect.objectContaining({ + id: 'w', + providerId: 'w', + displayState: 'working', + name: 'Review', + detail: { kind: 'role', agentType: 'Explore' }, + firstObservedAt: 10, + recencyAt: 900, + settled: false, + canStop: false + }), + expect.objectContaining({ id: 'i', displayState: 'idle', name: 'writer', detail: null }), + expect.objectContaining({ + id: 'u', + displayState: 'unverifiable', + detail: { kind: 'no-update' } + }) + ]) + expect(rows.every((row) => row.observedAt === undefined)).toBe(true) + }) + + it('decays live legacy states under a stale parent, as before', () => { + const rows = buildLegacyAgentChildRowModels( + [ + { id: 'w', state: 'working', startedAt: 10 }, + { id: 'q', state: 'waiting', startedAt: 10 }, + { id: 'i', state: 'idle', startedAt: 10 } + ], + STALE + ) + expect(rows.map((row) => row.displayState)).toEqual(['unverifiable', 'unverifiable', 'idle']) + }) +}) + +describe('buildLegacyTaskRowModels', () => { + it('keeps the state the host decided and says only its reason', () => { + const rows = buildLegacyTaskRowModels( + [ + { id: 'w', kind: 'agent', description: 'Review', state: 'working', startedAt: 10 }, + { id: 'q', kind: 'agent', description: 'Approve', state: 'waiting' }, + { id: 'u', kind: 'agent', description: 'Lost', state: 'unverifiable' }, + { id: 'm', kind: 'monitor', description: 'tail -f log' }, + { id: 'c', kind: 'command', description: 'npm test', stoppable: false } + ], + [{ id: 'd', kind: 'agent', description: 'Done', totalTokens: 900 }] + ) + expect(rows.map((row) => [row.id, row.displayState, row.detail])).toEqual([ + ['d', 'done', null], + ['w', 'working', null], + ['q', 'waiting', { kind: 'reason', state: 'waiting' }], + ['u', 'unverifiable', { kind: 'reason', state: 'unverifiable' }], + ['m', 'monitoring', null], + ['c', 'working', null] + ]) + expect(rows.map((row) => row.canStop)).toEqual([true, true, true, true, true, false]) + expect(rows[0]).toMatchObject({ settled: true, totalTokens: 900 }) + }) + + it('lets a resumed live task replace its retained settled row', () => { + const rows = buildLegacyTaskRowModels( + [{ id: 'r', kind: 'agent', state: 'working' }], + [{ id: 'r', kind: 'agent', state: 'done' }] + ) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ settled: false, displayState: 'working' }) + }) +}) + +describe('one label rule for every shape a host publishes', () => { + it.each(['task', ' Subagent ', 'unknown', ' '])( + 'skips the placeholder %j on all three', + (label) => { + const [fromView] = buildAgentChildRowModels( + [view('a', { description: label, agentType: 'Explore' })], + FRESH + ) + const [fromSnapshot] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10, description: label, agentType: 'Explore' }], + FRESH + ) + const [fromRoster] = buildLegacyTaskRowModels( + [{ id: 'a', kind: 'agent', state: 'working', description: label, name: 'Explore' }], + [] + ) + expect([fromView.name, fromSnapshot.name, fromRoster.name]).toEqual([ + 'Explore', + 'Explore', + 'Explore' + ]) + } + ) + + it('leaves a row with no usable label unnamed on all three', () => { + const [fromSnapshot] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10, description: 'task' }], + FRESH + ) + const [fromRoster] = buildLegacyTaskRowModels( + [{ id: 'a', kind: 'agent', state: 'working', description: 'task' }], + [] + ) + expect([only([view('a', { description: 'task', agentType: undefined })]).name]).toEqual(['']) + expect([fromSnapshot.name, fromRoster.name]).toEqual(['', '']) + }) +}) + +describe('agentChildRowContextForParent: the reader clock', () => { + const MIRRORED_PARENT = { + // The host's clock runs 20 minutes ahead of this machine's. + updatedAt: 1_200_000 + 20 * 60_000, + evidenceObservedAt: 1_190_000 + 20 * 60_000, + mirroredEvidenceReceivedAt: 1_190_000 + } + + it("measures a mirrored parent on this machine's receipt clock, never the host's", () => { + const context = agentChildRowContextForParent(MIRRORED_PARENT, false) + expect(context).toMatchObject({ parentObservedAt: 1_190_000, hostClockOffsetMs: -20 * 60_000 }) + const [legacy] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10 }], + context + ) + expect(legacy.recencyAt).toBe(1_190_000) + // A view's own host stamp moves onto the reader clock; its host-clock age is kept. + const [child] = buildAgentChildRowModels( + [view('a', { observedAt: 1_100_000 + 20 * 60_000 })], + context + ) + expect(child.recencyAt).toBe(1_100_000) + expect(child.observedAt).toBe(1_100_000 + 20 * 60_000) + }) + + it('leaves a parent observed on this machine on its own clock', () => { + expect( + agentChildRowContextForParent({ updatedAt: 500, evidenceObservedAt: 400 }, true) + ).toEqual({ + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 400, + hostClockOffsetMs: 0 + }) + }) +}) diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts new file mode 100644 index 000000000000..8e1cf606eea1 --- /dev/null +++ b/src/shared/agent-child-row-model.ts @@ -0,0 +1,376 @@ +/** + * One row model for every surface that lists child work (the sidebar's child rows and the chat + * strip), with one builder per shape a host may publish. All three name a row with one label rule + * (`usableAgentChildLabel`) and decide what it says with one detail rule (`agentChildRowDetail`): + * - `buildAgentChildRowModels`: the host's child views (`children`), the source of record. + * - `buildLegacyAgentChildRowModels`: the `subagents` snapshot. + * - `buildLegacyTaskRowModels`: the session feed's `tasks` / `settledTasks` roster. + * + * The two legacy builders exist only for hosts that publish no views, and each is deleted, not + * migrated, when its last publisher is gone: the snapshot builder once CLI panes publish + * `children` and no supported paired host predates that; the roster builder once no supported + * paired host predates child views on the session feed. + */ +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import type { AgentChildWorkKind } from './agent-status-child-work' +import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState, + type AgentChildDisplayState +} from './agent-status-child-work-display' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import { + agentStatusAuthorityObservedAt, + agentStatusEvidenceObservedAt +} from './agent-status-freshness' +import type { AgentStatusEntry, AgentSubagentSnapshot } from './agent-status-types' + +/** What a child row says beside its name. Surfaces format it; they never decide it. */ +export type AgentChildRowDetail = + /** The tool the child is running now, as `tool: input` — the line a CLI agent row shows. */ + | { kind: 'operation'; toolName: string; input?: string } + /** Its own work is over and only work it owns still runs; the tool line is stale. */ + | { kind: 'monitoring' } + | { kind: 'message'; text: string } + /** Settled, and the lane cannot say how. */ + | { kind: 'ended' } + /** How long since the last evidence for this child (see `recencyAt`). */ + | { kind: 'no-update' } + /** The child's role, when nothing more specific is known. */ + | { kind: 'role'; agentType: string } + /** A host that publishes only a run state: that state's reason word. */ + | { kind: 'reason'; state: 'waiting' | 'blocked' | 'unverifiable' } + +/** One child row, for every surface that lists child work (sidebar and chat strip). */ +export type AgentChildRowModel = { + /** Row identity: the host's child id, or the provider id an old host names it by. */ + id: string + /** The id a targeted stop names; absent when the host holds none. */ + providerId?: string + kind: AgentChildWorkKind + displayState: AgentChildDisplayState + /** '' when the child reported no label; a surface then names the row by its state. */ + name: string + agentType?: string + model?: string + detail: AgentChildRowDetail | null + /** When the child was first seen: the elapsed anchor. */ + firstObservedAt: number + /** Last evidence for THIS child. Absent from a host that reports none. */ + observedAt?: number + /** The reader-clock time a "no update" reading measures from: the child's own evidence, else + * its parent's. */ + recencyAt: number + settledAt?: number + totalTokens?: number + canStop: boolean + settled: boolean + /** Work this child owns (a nested agent, a shell it launched), in host order. */ + owned: AgentChildRowModel[] +} + +export type AgentChildRowContext = { + /** A stale parent makes every live claim beneath it unverifiable. */ + parentEvidenceFresh: boolean + transportObservation: 'live' | 'unverifiable' + /** The parent's last evidence on the READER's clock (its receipt time when mirrored from another + * host): recency for a child whose host reports no clock of its own. */ + parentObservedAt: number + /** Reader clock minus the host's, at that evidence; 0 for a parent observed on this machine. Moves + * a child's host-stamped clock onto the reader's, so no age subtracts across two machines. */ + hostClockOffsetMs: number +} + +/** The context a parent row gives its children. Every surface that lists one parent's children + * builds it here, from the same parent row, so a lost child reads the same everywhere. */ +export function agentChildRowContextForParent( + parent: Pick< + AgentStatusEntry, + 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'subagentObservation' + >, + parentEvidenceFresh: boolean +): AgentChildRowContext { + // The parent row decays on this same clock, so a child's silence reads the same as its parent's. + const parentObservedAt = agentStatusEvidenceObservedAt(parent) + return { + parentEvidenceFresh, + transportObservation: parent.subagentObservation ?? 'live', + parentObservedAt, + hostClockOffsetMs: parentObservedAt - agentStatusAuthorityObservedAt(parent) + } +} + +/** Provider strings that carry no identity; the next label wins. */ +const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent']) + +/** A provider label that names the child, or undefined for an empty or placeholder one. */ +export function usableAgentChildLabel(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && !PLACEHOLDER_LABELS.has(trimmed.toLowerCase()) ? trimmed : undefined +} + +// A live claim beneath a parent Orca no longer hears from asserts nothing; settled history stands. +function withFreshness( + displayState: AgentChildDisplayState, + context: AgentChildRowContext +): AgentChildDisplayState { + if ( + displayState === 'done' || + displayState === 'failed' || + displayState === 'interrupted' || + displayState === 'idle' + ) { + return displayState + } + return resolveAgentChildWorkFreshness({ + state: displayState, + membership: 'live', + parentEvidenceFresh: context.parentEvidenceFresh, + transportObservation: context.transportObservation + }) +} + +type AgentChildRowDetailSource = { + /** `run-state`: the host reported only the child's run state (the legacy task roster). */ + evidence: 'child' | 'run-state' + kind: AgentChildWorkKind + name: string + agentType?: string + operation?: { toolName: string; input?: string } + lastMessage?: string + settled: boolean +} + +// A role that restates the name adds nothing. +function roleDetail(source: AgentChildRowDetailSource): AgentChildRowDetail | null { + if (source.kind !== 'agent') { + return null + } + const agentType = source.agentType ?? 'unknown' + return source.name.trim() === agentType.trim() ? null : { kind: 'role', agentType } +} + +function messageOrRole(source: AgentChildRowDetailSource): AgentChildRowDetail | null { + const text = source.lastMessage?.trim() + return text ? { kind: 'message', text } : roleDetail(source) +} + +/** The one rule for what a child row says, in the order a CLI agent row decides it. */ +function agentChildRowDetail( + source: AgentChildRowDetailSource, + displayState: AgentChildDisplayState +): AgentChildRowDetail | null { + if (source.evidence === 'run-state') { + return displayState === 'waiting' || + displayState === 'blocked' || + displayState === 'unverifiable' + ? { kind: 'reason', state: displayState } + : null + } + switch (displayState) { + case 'unverifiable': + return { kind: 'no-update' } + case 'monitoring': + // A shell or monitor names itself; only an agent's own tool line goes stale. + return source.kind === 'agent' ? { kind: 'monitoring' } : null + case 'working': + case 'waiting': + return source.operation + ? { + kind: 'operation', + toolName: source.operation.toolName, + ...(source.operation.input !== undefined ? { input: source.operation.input } : {}) + } + : messageOrRole(source) + case 'blocked': + case 'done': + case 'failed': + return messageOrRole(source) + case 'interrupted': + return roleDetail(source) + case 'idle': + return source.settled ? { kind: 'ended' } : roleDetail(source) + } +} + +function viewName(view: AgentChildWorkView): string { + return ( + usableAgentChildLabel(view.description) ?? + usableAgentChildLabel(view.name) ?? + usableAgentChildLabel(view.agentType) ?? + '' + ) +} + +function rowFromView( + view: AgentChildWorkView, + views: readonly AgentChildWorkView[], + ownedByOwner: ReadonlyMap, + context: AgentChildRowContext, + path: ReadonlySet +): AgentChildRowModel { + const displayState = withFreshness( + deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)), + context + ) + const settled = view.membership === 'settled' + const name = viewName(view) + const nextPath = new Set(path).add(view.id) + return { + id: view.id, + ...(view.providerId !== undefined ? { providerId: view.providerId } : {}), + kind: view.kind, + displayState, + name, + ...(view.agentType !== undefined ? { agentType: view.agentType } : {}), + ...(view.model !== undefined ? { model: view.model } : {}), + detail: agentChildRowDetail( + { + evidence: 'child', + kind: view.kind, + name, + agentType: view.agentType, + operation: view.operation, + lastMessage: view.lastMessage, + settled + }, + displayState + ), + firstObservedAt: view.firstObservedAt, + observedAt: view.observedAt, + recencyAt: view.observedAt + context.hostClockOffsetMs, + ...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}), + ...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}), + canStop: !settled && view.stoppable && view.providerId !== undefined, + settled, + owned: (ownedByOwner.get(view.id) ?? []) + .filter((owned) => !nextPath.has(owned.id)) + .map((owned) => rowFromView(owned, views, ownedByOwner, context, nextPath)) + } +} + +/** + * Child rows from the host's views: the main agent's children at the top, and everything a child + * owns nested beneath it. Display state comes from the shared child fold, so a finished subagent + * whose shell still runs reads `monitoring` exactly as a CLI agent does. + */ +export function buildAgentChildRowModels( + views: readonly AgentChildWorkView[], + context: AgentChildRowContext +): AgentChildRowModel[] { + const ownedByOwner = new Map() + for (const view of views) { + if (view.parentChildWorkId !== undefined) { + ownedByOwner.set(view.parentChildWorkId, [ + ...(ownedByOwner.get(view.parentChildWorkId) ?? []), + view + ]) + } + } + return views + .filter((view) => view.parentChildWorkId === undefined) + .map((view) => rowFromView(view, views, ownedByOwner, context, new Set())) +} + +/** + * Child rows from a host that sends only the legacy `subagents` snapshot (an old host, or any CLI + * pane): live agents with a lifecycle state and a spawn time, and no clock of their own. + */ +export function buildLegacyAgentChildRowModels( + subagents: readonly AgentSubagentSnapshot[], + context: AgentChildRowContext +): AgentChildRowModel[] { + return subagents.map((subagent) => { + const displayState = withFreshness( + deriveAgentChildDisplayState({ state: subagent.state, membership: 'live' }, null), + context + ) + const name = + usableAgentChildLabel(subagent.description) ?? usableAgentChildLabel(subagent.agentType) ?? '' + return { + id: subagent.id, + providerId: subagent.id, + kind: 'agent', + displayState, + name, + ...(subagent.agentType !== undefined ? { agentType: subagent.agentType } : {}), + ...(subagent.model !== undefined ? { model: subagent.model } : {}), + detail: agentChildRowDetail( + { evidence: 'child', kind: 'agent', name, agentType: subagent.agentType, settled: false }, + displayState + ), + firstObservedAt: subagent.startedAt, + recencyAt: context.parentObservedAt, + canStop: false, + settled: false, + owned: [] + } + }) +} + +function legacyTaskDisplayState( + task: AgentSessionBackgroundTask, + settled: boolean +): AgentChildDisplayState { + if (task.state) { + return task.state + } + if (settled) { + return 'done' + } + return task.kind === 'monitor' ? 'monitoring' : 'working' +} + +// The host decided each row's state; the row keeps it. +function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel { + const displayState = legacyTaskDisplayState(task, settled) + const name = usableAgentChildLabel(task.description) ?? usableAgentChildLabel(task.name) ?? '' + const startedAt = task.startedAt ?? 0 + return { + id: task.id, + providerId: task.id, + kind: task.kind, + displayState, + name, + detail: agentChildRowDetail( + { evidence: 'run-state', kind: task.kind, name, settled }, + displayState + ), + firstObservedAt: startedAt, + recencyAt: startedAt, + ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}), + // Absent means stoppable: a host predating the field published only rows its stop could act on. + canStop: task.stoppable !== false, + settled, + owned: [] + } +} + +/** + * Rows from a host that publishes only the session feed's task roster (a host predating child + * views): flat, each with the run state the host decided and no clock beyond its start. + */ +export function buildLegacyTaskRowModels( + tasks: readonly AgentSessionBackgroundTask[], + settledTasks: readonly AgentSessionBackgroundTask[] +): AgentChildRowModel[] { + // Older hosts can retain a previous turn beside its resumed live task; the live row wins. + const rows = new Map() + for (const [roster, settled] of [ + [settledTasks, true], + [tasks, false] + ] as const) { + for (const task of roster) { + rows.set(task.id, legacyTaskRow(task, settled)) + } + } + return [...rows.values()] +} + +/** Every row of a tree, owners before what they own. */ +export function flattenAgentChildRowModels( + rows: readonly AgentChildRowModel[] +): AgentChildRowModel[] { + return rows.flatMap((row) => [row, ...flattenAgentChildRowModels(row.owned)]) +} diff --git a/src/shared/agent-status-child-work-display.ts b/src/shared/agent-status-child-work-display.ts new file mode 100644 index 000000000000..c83957199f99 --- /dev/null +++ b/src/shared/agent-status-child-work-display.ts @@ -0,0 +1,114 @@ +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentChildWorkId, AgentChildWorkOutcome } from './agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import type { AgentStatusState } from './agent-status-types' +import { groupedBy } from './grouped-by' + +// What a surface derives from child views. Kept apart from the projection, which resolves owners +// against status subjects and so reaches host-only code: every surface, renderer included, loads +// this module. + +type AgentChildWorkOwnershipView = Pick< + AgentChildWorkView, + 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId' +> + +/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */ +export function agentChildWorkOwnedLiveness( + views: readonly AgentChildWorkOwnershipView[], + ownerId: AgentChildWorkId +): AgentChildWorkLiveness { + const owned = groupedBy(views, (view) => view.parentChildWorkId) + const seen = new Set([ownerId]) + const frontier = [ownerId] + const liveDescendants: AgentChildWorkOwnershipView[] = [] + for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) { + for (const view of owned.get(owner) ?? []) { + if (!seen.has(view.id)) { + seen.add(view.id) + frontier.push(view.id) + if (view.membership === 'live') { + liveDescendants.push(view) + } + } + } + } + return agentChildWorkLiveness(liveDescendants) +} + +/** The dot a child row renders; every value is an `AgentStateDot` state. */ +export type AgentChildDisplayState = + | 'working' + | 'monitoring' + | 'waiting' + | 'blocked' + | 'done' + | 'failed' + | 'interrupted' + | 'idle' + | 'unverifiable' + +const SETTLED_DISPLAY_STATE: Record = { + succeeded: 'done', + failed: 'failed', + cancelled: 'interrupted', + // Neutral: an ending the lane cannot classify asserts nothing. + unknown: 'idle' +} + +/** + * A child's display state, through the same fold that decides a parent row's: work that is idle + * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it + * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold. + */ +export function deriveAgentChildDisplayState( + view: Pick, + ownedLiveness: AgentChildWorkLiveness +): AgentChildDisplayState { + if (view.state === 'unverifiable') { + return 'unverifiable' + } + // Stored only by a shell or a monitor, and neither owns work. + if (view.state === 'monitoring') { + return 'monitoring' + } + const leadState: AgentStatusState = + view.membership === 'settled' || view.state === 'done' || view.state === 'idle' + ? 'done' + : view.state + // A child's cancel never hides the work it left running. + const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false } + const folded = foldAgentLeadStatus(foldInput) + if (folded.stateName !== 'done') { + return folded.workingMode ?? folded.stateName + } + return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown'] +} + +/** + * A display state in the run-state words a host publishes and every older reader already speaks: + * a failure reads as `blocked`, a cancel as `idle`. The row's own dot keeps the real outcome. + */ +export function agentChildRunStateFor( + displayState: AgentChildDisplayState +): AgentSessionBackgroundTaskRunState { + switch (displayState) { + case 'failed': + return 'blocked' + case 'interrupted': + return 'idle' + case 'working': + case 'monitoring': + case 'waiting': + case 'blocked': + case 'done': + case 'idle': + case 'unverifiable': + return displayState + } +} diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts index 9c79a5451835..da0de4e752cd 100644 --- a/src/shared/agent-status-child-work-view.test.ts +++ b/src/shared/agent-status-child-work-view.test.ts @@ -15,7 +15,9 @@ import { } from './agent-status-child-work-projection' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, + deriveAgentChildDisplayState +} from './agent-status-child-work-display' +import { projectAgentChildWorkViews, type AgentChildWorkView, type AgentChildWorkViewAlias diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 8c70f6a3fea9..bfcbcfc11d5a 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -1,4 +1,3 @@ -import { foldAgentLeadStatus } from './agent-lead-status-fold' import { AGENT_CHILD_WORK_ALIAS_KINDS, type AgentChildWorkAliasInput, @@ -15,12 +14,8 @@ import { type AgentChildWorkOutcome, type AgentChildWorkState } from './agent-status-child-work' -import { - agentChildWorkLiveness, - type AgentChildWorkLiveness -} from './agent-status-child-work-liveness' import { agentStatusSubjectsEqual } from './agent-status-subject' -import type { AgentStatusState } from './agent-status-types' +import { groupedBy } from './grouped-by' /** What a surface reads about one child: a read-only projection of the host's record. * Host bookkeeping (residency, invocation history, provenance, aliases) never travels. A view @@ -83,21 +78,6 @@ function providerIdFor( return undefined } -// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it. -function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map { - const groups = new Map() - for (const item of items) { - const key = keyOf(item) - const group = groups.get(key) - if (group) { - group.push(item) - } else { - groups.set(key, [item]) - } - } - return groups -} - function isOnOwnershipCycle( start: AgentChildWorkInput, byId: ReadonlyMap @@ -161,80 +141,3 @@ export function projectAgentChildWorkViews( } }) } - -type AgentChildWorkOwnershipView = Pick< - AgentChildWorkView, - 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId' -> - -/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */ -export function agentChildWorkOwnedLiveness( - views: readonly AgentChildWorkOwnershipView[], - ownerId: AgentChildWorkId -): AgentChildWorkLiveness { - const owned = groupedBy(views, (view) => view.parentChildWorkId) - const seen = new Set([ownerId]) - const frontier = [ownerId] - const liveDescendants: AgentChildWorkOwnershipView[] = [] - for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) { - for (const view of owned.get(owner) ?? []) { - if (!seen.has(view.id)) { - seen.add(view.id) - frontier.push(view.id) - if (view.membership === 'live') { - liveDescendants.push(view) - } - } - } - } - return agentChildWorkLiveness(liveDescendants) -} - -/** The dot a child row renders; every value is an `AgentStateDot` state. */ -export type AgentChildDisplayState = - | 'working' - | 'monitoring' - | 'waiting' - | 'blocked' - | 'done' - | 'failed' - | 'interrupted' - | 'idle' - | 'unverifiable' - -const SETTLED_DISPLAY_STATE: Record = { - succeeded: 'done', - failed: 'failed', - cancelled: 'interrupted', - // Neutral: an ending the lane cannot classify asserts nothing. - unknown: 'idle' -} - -/** - * A child's display state, through the same fold that decides a parent row's: work that is idle - * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it - * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold. - */ -export function deriveAgentChildDisplayState( - view: Pick, - ownedLiveness: AgentChildWorkLiveness -): AgentChildDisplayState { - if (view.state === 'unverifiable') { - return 'unverifiable' - } - // Stored only by a shell or a monitor, and neither owns work. - if (view.state === 'monitoring') { - return 'monitoring' - } - const leadState: AgentStatusState = - view.membership === 'settled' || view.state === 'done' || view.state === 'idle' - ? 'done' - : view.state - // A child's cancel never hides the work it left running. - const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false } - const folded = foldAgentLeadStatus(foldInput) - if (folded.stateName !== 'done') { - return folded.workingMode ?? folded.stateName - } - return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown'] -} diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index 25d6b6413d4e..4fb741b5ec16 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -2,12 +2,17 @@ // String normalizers shared by every agent-status payload field: trim/fold to // a single line for previews, preserve structure for multiline bodies, and // truncate without splitting surrogate pairs. Extracted from -// agent-status-types.ts, which owns the payload shapes and per-field caps. +// agent-status-types.ts, which owns the payload shapes and the caps no other module applies. import { compactDispatchPromptForStatus } from './orca-dispatch-status-prompt' /** Maximum character length for the prompt field. Truncated on parse. */ export const AGENT_STATUS_MAX_FIELD_LENGTH = 200 +/** Maximum character length for the toolInput preview. */ +export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160 +/** Maximum character length for the agentType label. Truncated on parse. */ +export const AGENT_TYPE_MAX_LENGTH = 40 +export const AGENT_MODEL_MAX_LENGTH = 120 const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64 const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8 diff --git a/src/shared/agent-status-subagent-snapshot.ts b/src/shared/agent-status-subagent-snapshot.ts new file mode 100644 index 000000000000..b5a9f5af8fa4 --- /dev/null +++ b/src/shared/agent-status-subagent-snapshot.ts @@ -0,0 +1,108 @@ +// A pane's in-process subagents as the hook lane publishes them on a status row: the snapshot +// shape, its normalization at every admission point, and the equality stores reuse arrays by. + +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH, + normalizeOptionalField +} from './agent-status-field-normalization' + +export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable' + +/** A live in-process child of the pane's provider session. Rendered as an + * indented child row with no PTY of its own. */ +export type AgentSubagentSnapshot = { + /** Provider-assigned lifecycle id. */ + id: string + agentType?: string + /** Provider model used by this child, when exposed by its lifecycle event. */ + model?: string + description?: string + state: AgentSubagentState + /** Timestamp (ms) when this subagent was first observed. */ + startedAt: number +} + +/** Maximum subagent child rows carried per status entry. Bounds per-pane cache + * and IPC fanout against a runaway spawner. */ +export const AGENT_STATUS_MAX_SUBAGENTS = 32 +const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 + +function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null + } + const obj = value as Record + if (typeof obj.id !== 'string') { + return null + } + const id = obj.id.trim() + if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) { + return null + } + if ( + obj.state !== 'working' && + obj.state !== 'blocked' && + obj.state !== 'waiting' && + obj.state !== 'idle' && + obj.state !== 'unverifiable' + ) { + return null + } + return { + id, + state: obj.state, + startedAt: + typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0, + agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), + model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), + description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + } +} + +export function normalizeAgentSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined + } + const normalized: AgentSubagentSnapshot[] = [] + for (const item of value) { + const snapshot = normalizeSubagentSnapshot(item) + if (snapshot) { + normalized.push(snapshot) + if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) { + break + } + } + } + return normalized.length > 0 ? normalized : undefined +} + +/** Structural equality for subagent lists so stores can reuse the previous + * array reference (and skip fanout) when nothing actually changed. */ +export function agentSubagentsEqual( + a: AgentSubagentSnapshot[] | undefined, + b: AgentSubagentSnapshot[] | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return !a && !b + } + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if ( + x.id !== y.id || + x.state !== y.state || + x.startedAt !== y.startedAt || + x.agentType !== y.agentType || + x.model !== y.model || + x.description !== y.description + ) { + return false + } + } + return true +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0150ef2b7fdc..acc7e4f42554 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -8,8 +8,12 @@ import type { AgentStateHistoryEntry } from './agent-state-history' import { isAgentJournalTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' +import type { AgentChildWorkView } from './agent-status-child-work-view' import type { TuiAgent } from './tui-agent' import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH, normalizeInteractivePromptField, normalizeOptionalField, normalizeOptionalMultilineField, @@ -18,7 +22,23 @@ import { } from './agent-status-field-normalization' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { + normalizeAgentSubagentsField, + type AgentSubagentSnapshot +} from './agent-status-subagent-snapshot' + +export { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH +} from './agent-status-field-normalization' +export { + AGENT_STATUS_MAX_SUBAGENTS, + agentSubagentsEqual, + type AgentSubagentSnapshot, + type AgentSubagentState +} from './agent-status-subagent-snapshot' export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, @@ -53,22 +73,6 @@ export type AgentStatusOrchestrationContext = { attention?: OrchestrationFleetAttention } -export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable' - -/** A live in-process child of the pane's provider session. Rendered as an - * indented child row with no PTY of its own. */ -export type AgentSubagentSnapshot = { - /** Provider-assigned lifecycle id. */ - id: string - agentType?: string - /** Provider model used by this child, when exposed by its lifecycle event. */ - model?: string - description?: string - state: AgentSubagentState - /** Timestamp (ms) when this subagent was first observed. */ - startedAt: number -} - export type AgentStatusEntry = { /** Renderer-local status-feed confirmation for children; absent on hook rows. */ subagentObservation?: 'live' | 'unverifiable' @@ -142,6 +146,9 @@ export type AgentStatusEntry = { /** The main agent's own state; absent from old hosts and from writers that carry no main agent fact * (OSC, launch seeds), where readers fall back to `state`. */ mainAgent?: AgentMainAgentStatus + /** The host's child-work views for this session, when it publishes them; child rows then read + * these instead of `subagents`. Absent from older hosts. */ + children?: AgentChildWorkView[] /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata @@ -237,8 +244,6 @@ export function pickParsedAgentStatusPayload( */ /** Maximum character length for the toolName field. */ export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60 -/** Maximum character length for the toolInput preview. */ -export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160 /** Maximum character length for the lastAssistantMessage preview. * Why: 8 KB fits a multi-paragraph summary while bounding per-pane cache against a buggy/malicious agent spamming huge strings. */ export const AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH = 8000 @@ -260,67 +265,11 @@ const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) export function isAgentStatusState(value: unknown): value is AgentStatusState { return typeof value === 'string' && VALID_STATES.has(value) } -/** Maximum character length for the agentType label. Truncated on parse. */ -export const AGENT_TYPE_MAX_LENGTH = 40 -export const AGENT_MODEL_MAX_LENGTH = 120 -/** Maximum subagent child rows carried per status entry. Bounds per-pane cache - * and IPC fanout against a runaway spawner. */ -export const AGENT_STATUS_MAX_SUBAGENTS = 32 export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = { structuralTokens: 4096, nestingDepth: 16 } as const -const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 - -function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null { - if (typeof value !== 'object' || value === null) { - return null - } - const obj = value as Record - if (typeof obj.id !== 'string') { - return null - } - const id = obj.id.trim() - if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) { - return null - } - if ( - obj.state !== 'working' && - obj.state !== 'blocked' && - obj.state !== 'waiting' && - obj.state !== 'idle' && - obj.state !== 'unverifiable' - ) { - return null - } - return { - id, - state: obj.state, - startedAt: - typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0, - agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), - model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), - description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) - } -} - -function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined { - if (!Array.isArray(value) || value.length === 0) { - return undefined - } - const normalized: AgentSubagentSnapshot[] = [] - for (const item of value) { - const snapshot = normalizeSubagentSnapshot(item) - if (snapshot) { - normalized.push(snapshot) - if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } - } - } - return normalized.length > 0 ? normalized : undefined -} /** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ @@ -344,35 +293,6 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta } } -/** Structural equality for subagent lists so stores can reuse the previous - * array reference (and skip fanout) when nothing actually changed. */ -export function agentSubagentsEqual( - a: AgentSubagentSnapshot[] | undefined, - b: AgentSubagentSnapshot[] | undefined -): boolean { - if (a === b) { - return true - } - if (!a || !b || a.length !== b.length) { - return !a && !b - } - for (let i = 0; i < a.length; i++) { - const x = a[i] - const y = b[i] - if ( - x.id !== y.id || - x.state !== y.state || - x.startedAt !== y.startedAt || - x.agentType !== y.agentType || - x.model !== y.model || - x.description !== y.description - ) { - return false - } - } - return true -} - /** * Normalize and validate an already-parsed agent status object. Shared by the * JSON string entry point (`parseAgentStatusPayload`) and the object entry @@ -420,7 +340,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), - subagents: normalizeSubagentsField(obj.subagents), + subagents: normalizeAgentSubagentsField(obj.subagents), mainAgent: normalizeMainAgentStatusField(obj.mainAgent) } } diff --git a/src/shared/grouped-by.ts b/src/shared/grouped-by.ts new file mode 100644 index 000000000000..0797dca96201 --- /dev/null +++ b/src/shared/grouped-by.ts @@ -0,0 +1,14 @@ +// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it. +export function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map { + const groups = new Map() + for (const item of items) { + const key = keyOf(item) + const group = groups.get(key) + if (group) { + group.push(item) + } else { + groups.set(key, [item]) + } + } + return groups +}