diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 68f6e97e632..93d646b7b64 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -51,11 +52,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all - * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a * statement that no remote client wants them. Why it is one is not recorded here. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index 3d7fb944735..130f5961bc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -3,10 +3,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -export function indexedStatusFeedSession(session: { +export function indexedStatusFeedSession

(session: { journal: AgentSessionJournal child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null - provider?: AgentSessionHandleProvider + provider?: P }) { return { journal: session.journal, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts new file mode 100644 index 00000000000..48358892d81 --- /dev/null +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -0,0 +1,303 @@ +// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's +// canonical store, and the status summary every session list reads and the chat strip's channel +// both carry the same child records from it, while the parent row is folded from them too. +// +// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the +// record producer read the same child executions, so agreeing with it could not catch a defect in +// what those executions observe. Every expectation below is written out literally. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const THREAD = 'thread-switch' +const REVIEWER = 'thread-reviewer' +const ROUTES: Record = { + 'thread/start': { thread: { id: THREAD } }, + 'model/list': { + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + } +} + +type Row = Pick & { + outcome?: AgentChildWorkView['outcome'] + tool?: string + owner?: string +} + +/** What a surface would show of each child, independent of which channel carried it. */ +function rows(views: readonly AgentChildWorkView[] | undefined): Row[] { + const byId = new Map((views ?? []).map((view) => [view.id, view])) + return (views ?? []).map((view) => ({ + kind: view.kind, + ...(view.description ? { description: view.description } : {}), + state: view.state, + membership: view.membership, + ...(view.outcome ? { outcome: view.outcome } : {}), + ...(view.operation ? { tool: `${view.operation.toolName}: ${view.operation.input}` } : {}), + ...(view.parentChildWorkId + ? { owner: byId.get(view.parentChildWorkId)?.description ?? '?' } + : {}) + })) +} + +describe('the chat strip and the session list read the same host child records', () => { + let root: string + let server: AgentHookServer + + beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-child-records-switch-')) + server = new AgentHookServer() + }) + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + }) + + it('carries one record per child to both, and folds the parent row from them', async () => { + const connections: CodexAppServerConnectionHandlers[] = [] + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + connections.push(handlers) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => (method in ROUTES ? ROUTES[method] : {}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + // Production's sink wiring, onto a real hook server: no renderer is involved anywhere. + const statusSink: StructuredAgentSessionStatusSink = { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({ + ok: true + }) + await host.hold(SESSION, 'desktop-chat:1') + const summaries: AgentSessionStatusSummary[] = [] + host.subscribeStatus({ + id: 'session-list', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } else if (event.type === 'snapshot') { + summaries.push(...event.sessions.filter((session) => session.sessionId === SESSION)) + } + } + }) + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + host.subscribe({ + id: 'strip', + sessionId: SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + strip.push(event.backgroundTasks) + } + } + }) + // Journal writes land asynchronously; let each frame's rows publish before the next. + const notify = async (method: string, params: Record) => { + connections[0]?.onNotification?.(method, params) + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + value: Record + ) => notify(method, { threadId, turnId, item: value }) + const parentRow = () => server.getStatusSnapshot().find((row) => row.structuredHost) + const summaryRows = () => rows(summaries.at(-1)?.children) + const stripRows = () => rows(strip.at(-1)?.children ?? undefined) + const both = (expected: Row[]) => { + expect(summaryRows()).toEqual(expected) + expect(stripRows()).toEqual(expected) + } + + await notify('turn/started', { threadId: THREAD, turn: { id: 'p1', status: 'inProgress' } }) + await item('item/completed', THREAD, 'p1', { + type: 'agentMessage', + id: 'lead-1', + text: 'Spawning a reviewer' + }) + await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r1', status: 'inProgress' } }) + await item('item/started', THREAD, 'p1', { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: REVIEWER, + agentPath: '/root/review' + }) + both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }]) + + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'agent', + status: 'inProgress' + }) + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'inProgress' + }) + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm test' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + + // The parent's turn ends first: its own work is done, a subagent's is not. + await notify('turn/completed', { threadId: THREAD, turn: { id: 'p1', status: 'completed' } }) + expect(parentRow()).toMatchObject({ state: 'working' }) + expect(parentRow()).not.toHaveProperty('workingMode') + + // The child finishes; the shell it launched still runs. + await notify('turn/completed', { threadId: REVIEWER, turn: { id: 'r1', status: 'completed' } }) + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + expect(parentRow()).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // An older client folds the summary's task list: only live work is on it. + expect(summaries.at(-1)?.backgroundTasks?.map((task) => task.kind)).toEqual(['command']) + + await item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }) + expect(parentRow()).toMatchObject({ state: 'done' }) + // Finished children stay listed, with how they ended, until the session's next turn. + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'done', + membership: 'settled', + outcome: 'succeeded', + owner: 'review' + } + ]) + + await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + + // The provider exits: the records go with the session, on both. + connections[0]?.onExit?.(new Error('scripted provider exit')) + expect(server.getStructuredChildWorkViews(parentSubject(summaries))).toEqual([]) + }) +}) + +function parentSubject(summaries: AgentSessionStatusSummary[]) { + const summary = summaries.at(-1) + if (!summary) { + throw new Error('no summary') + } + return makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: summary.workspaceId, + workspaceKind: 'git-worktree' + }, + summary.sessionId + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 26f04ae2e3c..db4de629d01 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -302,6 +302,7 @@ export function NativeChatStructuredSession( agentLabel={agentLabel} startupPhase={hostExecution.phase} startupChildKey={hostExecution.childKey} + paneKey={paneKey} // Said once: on the pane when the failure took it, else here beside the transcript. A // failure that names nothing is only the pane reconnecting. error={ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx index da53ca1914c..638ed4f054d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx @@ -35,6 +35,7 @@ function statusElement( agentLabel="Claude" startupPhase={startupPhase} startupChildKey={startupChildKey} + paneKey="pane-1" error={null} composerError={null} isVisible diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 31518886150..c6f8316444f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n' import { useDelayedStatus } from '@/hooks/use-delayed-status' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context' type StoppingBackgroundTasks = { sessionId: string @@ -22,6 +23,8 @@ export function NativeChatStructuredSessionStatus(props: { /** The host's word on the provider child; `starting` is published but not yet answering. */ startupPhase: 'starting' | 'ready' | null startupChildKey: string | number | null + /** The session's own status row, whose verdict the strip's children read. */ + paneKey: string error: string | null /** A read the pane is reconnecting on its own: said plainly, not as an error. */ reconnecting?: boolean @@ -38,6 +41,7 @@ export function NativeChatStructuredSessionStatus(props: { props.startupPhase === 'starting' ? 'starting' : null, SLOW_STARTUP_NOTICE_DELAY_MS ) + const childRowContext = useStructuredSessionChildRowContext(props.paneKey) const onStop = (taskId?: string) => { const sessionId = props.sessionId @@ -95,6 +99,9 @@ export function NativeChatStructuredSessionStatus(props: { isVisible={props.isVisible} tasks={props.backgroundTasks.tasks} settledTasks={props.backgroundTasks.settledTasks} + {...(props.backgroundTasks.children + ? { childViews: props.backgroundTasks.children, childRowContext } + : {})} indicatorActive={props.backgroundTasks.isMonitoring} supportsTaskStop={props.backgroundTasks.supportsStop} supportsStopAll={props.backgroundTasks.supportsStopAll} diff --git a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx new file mode 100644 index 00000000000..966d8a7b0b1 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom + +// The switch, wired end to end in the renderer: a host that publishes its child records reaches the +// sidebar through the status bridge and the chat strip through the session channel, and both read +// one child the same way — including once the parent's transport is lost or its row goes stale. + +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import type { AppState } from '@/store/types' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +const mocks = vi.hoisted(() => ({ + store: null as null | { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void + }, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() +})) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: vi.fn(async () => true) +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +vi.mock('@/components/sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus' +import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +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' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const MINUTE = 60_000 +const tab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab +const PANE_KEY = structuredAgentSessionPaneKey(tab.id, tab.entityId) + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + const now = Date.now() + return { + id, + providerId: `thread-${id}`, + kind: 'agent', + description: `Audit ${id}`, + state: 'working', + membership: 'live', + firstObservedAt: now - 5 * MINUTE, + observedAt: now - 2 * MINUTE, + stoppable: false, + invocation: { invocationId: `turn-${id}`, generation: 1 }, + ...overrides + } +} + +function summary(over: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: tab.entityId, + workspaceId: tab.worktreeId, + agent: 'codex', + status: 'idle', + hostExecutionOwned: true, + latestPrompt: 'fan out', + updatedAt: Date.now(), + ...over + } +} + +function feed(): (event: AgentSessionStatusEvent) => void { + const call = mocks.subscribeStatus.mock.calls[0] + if (!call) { + throw new Error('status feed not subscribed') + } + return call[1] as (event: AgentSessionStatusEvent) => void +} + +function row(): AgentStatusEntry { + const entry = mocks.store?.getState().agentStatusByPaneKey[PANE_KEY] + if (!entry) { + throw new Error('no structured row') + } + return entry +} + +type RenderedRow = { dot: string; lead: string; trail: string } + +function readRow(root: Element, separator: string): RenderedRow { + const [lead, trail] = [...(root.querySelector('span.truncate')?.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 ?? '') + } +} + +/** The sidebar's rows for the session, built exactly as the worktree card builds them. */ +function sidebarRows(): RenderedRow[] { + const entry = row() + const terminalTab: TerminalTab = { + id: tab.id, + ptyId: null, + worktreeId: tab.worktreeId, + title: tab.label, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const parentIsFresh = isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + return buildSubagentChildRows({ parentEntry: entry, tab: terminalTab, parentIsFresh }).map( + (agent) => { + const container = document.createElement('div') + container.innerHTML = renderToStaticMarkup( + + {}} /> + + ) + return readRow(container, ' - ') + } + ) +} + +/** The strip's rows, from the real session-status component over the channel's roster. */ +function stripRows(container: HTMLElement): RenderedRow[] { + return [...container.querySelectorAll('[data-native-chat-background-tasks] li')] + .filter((item) => item.querySelector(':scope > span.truncate')) + .map((item) => readRow(item, ' · ')) +} + +function Surfaces(props: { roster: AgentSessionBackgroundTaskState }): React.JSX.Element { + return ( + + + undefined} + /> + + ) +} + +describe('the switch: both surfaces read the host child records', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [tab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + async function mountWith(children: AgentChildWorkView[]) { + // The summary and the channel carry the same views; the summary omits usage. + const roster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + children + } + const rendered = render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed()({ type: 'snapshot', sessions: [summary({ children })] })) + const header = rendered.container.querySelector('[aria-expanded]') + if (header) { + fireEvent.click(header) + } + return rendered + } + + it("copies the host's views onto the row and derives the legacy roster from them", async () => { + const shell = view('shell', { + kind: 'command', + providerId: 'codex-command:thread:thread-a:cmd-1', + description: 'npm run dev', + state: 'monitoring', + parentChildWorkId: 'a' + }) + const finished = view('a', { + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: Date.now() - MINUTE, + lastMessage: 'Parser is clean' + }) + const running = view('b') + await mountWith([finished, shell, running]) + expect(row().children).toEqual([finished, shell, running]) + // The legacy roster lists live agents only, under the id a Codex subagent's row always had. + expect(row().subagents).toEqual([ + expect.objectContaining({ id: 'codex-agent:thread-b', state: 'working' }) + ]) + // An idle lead held open by a live subagent reads working, from the records. + expect(row()).toMatchObject({ state: 'working' }) + }) + + it('shows one child the same way on both surfaces, live, lost, and stale', async () => { + const children = [ + view('a', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: Date.now() } + }) + ] + const { container } = await mountWith(children) + const live = { dot: 'Working', lead: 'Audit a', trail: 'Bash: npm test' } + expect(sidebarRows()).toEqual([live]) + expect(stripRows(container)).toEqual([live]) + + // The status stream drops: the host is not heard from, so neither surface vouches for it. + act(() => feed()({ type: 'end' })) + const lost = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' } + await waitFor(() => expect(sidebarRows()).toEqual([lost])) + expect(stripRows(container)).toEqual([lost]) + }) + + it('reads a stale parent row the same way on both surfaces', async () => { + const { container } = await mountWith([view('a')]) + // The host no longer owns the session, and its last word is older than the freshness window. + act(() => + feed()({ + type: 'status', + session: summary({ + hostExecutionOwned: undefined, + status: 'working', + updatedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - MINUTE, + children: [view('a')] + }) + }) + ) + await waitFor(() => + expect(isExplicitAgentStatusFresh(row(), Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe(false) + ) + const stale = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' } + expect(sidebarRows()).toEqual([stale]) + expect(stripRows(container)).toEqual([stale]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts index a79e97f705a..a42227f56cf 100644 --- a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts +++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts @@ -5,21 +5,28 @@ // foreground fan-out is running work while the turn is still open. It stays // mounted through a running turn — turn state is not a filter on the rows, // because the producers publish only tasks they still have live evidence for. -// Only an idle session lets it animate or speak for itself. +// A host that publishes its child records also keeps a finished child listed, +// with how it ended, until the session's next turn. Only an idle session with +// live work lets the strip animate or speak for itself. import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import { agentChildWorkLiveness } from '../../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { decodeAgentChildWorkViews } from '../../../../shared/agent-status-child-work-view-wire' export type StructuredSessionBackgroundTasksView = { - /** The strip renders whenever the host reports monitoring — mid-turn included. */ + /** The strip renders whenever the host reports rows — mid-turn included. */ show: boolean /** Idle-only: gates the animated monitoring indicator and conversation * commands, never the strip itself. A running turn owns the voice. */ isMonitoring: boolean tasks: AgentSessionBackgroundTask[] settledTasks: AgentSessionBackgroundTask[] + /** The host's child records, when it publishes them; the rows then read these. */ + children?: AgentChildWorkView[] supportsStop: boolean supportsStopAll: boolean } @@ -29,11 +36,15 @@ export function structuredSessionBackgroundTasksView( turnId: string | null ): StructuredSessionBackgroundTasksView { const monitoring = backgroundTasks?.state === 'monitoring' + const children = monitoring ? decodeAgentChildWorkViews(backgroundTasks.children) : undefined + // A roster of finished children is shown, but nothing in it is running. + const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring return { show: monitoring, - isMonitoring: turnId === null && monitoring, + isMonitoring: turnId === null && liveWork, tasks: backgroundTasks?.tasks ?? [], settledTasks: backgroundTasks?.settledTasks ?? [], + ...(children ? { children } : {}), supportsStop: backgroundTasks?.supportsTaskStop === true, // Absent means the host predates the field and does accept an untargeted // stop; only a host that says `false` has none to offer. diff --git a/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts new file mode 100644 index 00000000000..c0703f5db1f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { + agentChildRowContextForParent, + type AgentChildRowContext +} from '../../../../shared/agent-child-row-model' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { useNow } from '@/hooks/use-now' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { useAppStore } from '@/store' + +/** The verdict a session's status row gives its children, exactly as the sidebar derives it. */ +export function structuredSessionChildRowContext( + parent: AgentStatusEntry, + now: number +): AgentChildRowContext { + return agentChildRowContextForParent( + parent, + isExplicitAgentStatusFresh(parent, now, AGENT_STATUS_STALE_AFTER_MS) + ) +} + +/** The strip's children read their parent's verdict from the same status row the sidebar reads, + * so one child never says "No update" in one place and "Working" in the other. */ +export function useStructuredSessionChildRowContext( + paneKey: string +): AgentChildRowContext | undefined { + const parent = useAppStore((state) => state.agentStatusByPaneKey[paneKey]) + // The freshness window is thirty minutes; a coarse shared tick is well inside its precision. + const now = useNow(30_000, parent !== undefined) + return useMemo( + () => (parent ? structuredSessionChildRowContext(parent, now) : undefined), + [parent, now] + ) +}