diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 188846461e4..1276287aca0 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -6,7 +6,6 @@ import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' -import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows' import { resolveAttention } from '../sidebar/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -220,116 +219,6 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'blocked' })]) }) - it('publishes agent-kind background tasks as the sidebar subagent children', async () => { - render() - await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) - - act(() => - feed().emit({ - type: 'snapshot', - sessions: [ - summary({ - backgroundTasks: [ - { - id: 'child-1', - kind: 'agent', - name: 'deep_review', - description: 'Review the diff', - state: 'working', - startedAt: 500 - }, - // A backgrounded shell is not a subagent; kinds stay distinct. - { id: 'shell-1', kind: 'command', description: 'sleep 180', state: 'working' } - ] - }) - ] - }) - ) - expect(statuses()).toEqual([ - expect.objectContaining({ - subagents: [ - { - id: 'child-1', - state: 'working', - startedAt: 500, - agentType: 'deep_review', - description: 'Review the diff' - } - ] - }) - ]) - - // An unchanged roster must not rewrite the store. - const writes = mocks.setAgentStatus.mock.calls.length - act(() => - feed().emit({ - type: 'status', - session: summary({ - backgroundTasks: [ - { - id: 'child-1', - kind: 'agent', - name: 'deep_review', - description: 'Review the diff', - state: 'working', - startedAt: 500 - }, - { id: 'shell-1', kind: 'command', description: 'sleep 180', state: 'working' } - ] - }) - }) - ) - expect(mocks.setAgentStatus.mock.calls.length).toBe(writes) - - act(() => - feed().emit({ - type: 'status', - session: summary({ - updatedAt: 2, - backgroundTasks: [ - { id: 'child-1', kind: 'agent', name: 'deep_review', state: 'waiting', startedAt: 500 } - ] - }) - }) - ) - expect(statuses()).toEqual([ - expect.objectContaining({ - subagents: [expect.objectContaining({ id: 'child-1', state: 'waiting' })] - }) - ]) - - // A summary without tasks ends the fan-out: children clear with it. - act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 3 }) })) - expect(statuses()).toEqual([expect.objectContaining({ subagents: undefined })]) - }) - - it('keeps quiet live children authoritative and reconfirms them per session after reconnect', async () => { - render() - await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) - const live = summary({ backgroundTasks: [{ id: 'child', kind: 'agent', state: 'working' }] }) - const childState = () => - buildSubagentChildRows({ - parentEntry: statuses()[0], - tab: structuredTab as never, - parentIsFresh: false - })[0]?.state - act(() => feed().emit({ type: 'snapshot', sessions: [live] })) - // A hook's evidence window has expired, but the host has not retracted its live task. - expect(childState()).toBe('working') - act(() => feed().emit({ type: 'end' })) - expect(childState()).toBe('unverifiable') - await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2)) - act(() => feed(1).emit({ type: 'snapshot', sessions: [] })) - expect(childState()).toBe('unverifiable') - act(() => feed(1).emit({ type: 'status', session: live })) - expect(childState()).toBe('working') - const writes = mocks.setAgentStatus.mock.calls.length - act(() => feed(1).emit({ type: 'status', session: live })) - expect(mocks.setAgentStatus).toHaveBeenCalledTimes(writes) - act(() => feed(1).emit({ type: 'status', session: summary({ backgroundTasks: [] }) })) - expect(childState()).toBeUndefined() - }) - it('carries the model, the running tool line, and the last assistant message', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 1bb54dc904a..47d6aec460f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,16 +1,7 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' -import type { - AgentSessionBackgroundTask, - AgentSessionStatusSummary -} from '../../../../shared/agent-session-wire' -import { - AGENT_STATUS_MAX_SUBAGENTS, - agentSubagentsEqual, - type AgentSubagentSnapshot, - type AgentSubagentState -} from '../../../../shared/agent-status-types' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { structuredAgentSessionPaneKey, structuredAgentSessionStatusState @@ -58,78 +49,17 @@ export function getStructuredAgentSessionTabs( function useStructuredAgentSessionStatusSummary( sessionId: string, target: RuntimeClientTarget -): { summary: AgentSessionStatusSummary | null; observation: 'live' | 'unverifiable' } { +): AgentSessionStatusSummary | null { const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) useEffect(() => feed.activate(), [feed]) - const summary = useSyncExternalStore( + return useSyncExternalStore( feed.subscribe, () => feed.getSnapshot().get(sessionId) ?? null, () => null ) - const observation = useSyncExternalStore( - feed.subscribe, - () => feed.getSessionObservation(sessionId), - () => 'unverifiable' as const - ) - return { summary, observation } } -/** Matches the wire-parse bound in `normalizeSubagentSnapshot`. */ -const SUBAGENT_ID_MAX_LENGTH = 64 - -function subagentStateFromTask(task: AgentSessionBackgroundTask): AgentSubagentState { - switch (task.state) { - case 'waiting': - return 'waiting' - case 'blocked': - return 'blocked' - case 'done': - case 'idle': - case 'unverifiable': - return 'idle' - // Absent state is an old host's live task; live means working here. - case 'working': - case 'monitoring': - case undefined: - return 'working' - } -} - -/** Sidebar children for a structured session: the agent-kind background tasks - * the host publishes, mapped to the sidebar's own subagent vocabulary rather - * than widening it. Kinds stay distinct — a backgrounded shell never counts - * as a subagent. */ -function subagentSnapshotsFromTasks( - tasks: AgentSessionBackgroundTask[] | undefined -): AgentSubagentSnapshot[] | undefined { - if (!tasks) { - return undefined - } - const snapshots: AgentSubagentSnapshot[] = [] - for (const task of tasks) { - const id = task.id.trim() - if (task.kind !== 'agent' || id.length === 0 || id.length > SUBAGENT_ID_MAX_LENGTH) { - continue - } - snapshots.push({ - id, - state: subagentStateFromTask(task), - startedAt: task.startedAt ?? 0, - ...(task.name ? { agentType: task.name } : {}), - ...(task.description ? { description: task.description } : {}) - }) - if (snapshots.length >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } - } - return snapshots.length > 0 ? snapshots : undefined -} - -function projectStatus( - tab: StructuredTab, - summary: AgentSessionStatusSummary | null, - observation: 'live' | 'unverifiable' -): void { +function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void { const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) const store = useAppStore.getState() // No persisted turn yet (or nothing known): the row shows no agent status at all. @@ -139,7 +69,6 @@ function projectStatus( } return } - const subagents = subagentSnapshotsFromTasks(summary.backgroundTasks) const desired = { // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. state: structuredAgentSessionStatusState(summary.status), @@ -151,7 +80,6 @@ function projectStatus( ...(summary.toolName ? { toolName: summary.toolName } : {}), ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), ...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } : {}), - ...(subagents ? { subagents, subagentObservation: observation } : {}), sessionBoundary: false } as const const current = store.agentStatusByPaneKey?.[paneKey] @@ -164,8 +92,6 @@ function projectStatus( current.toolName === summary.toolName && current.toolInput === summary.toolInput && current.lastAssistantMessage === summary.lastAssistantMessage && - agentSubagentsEqual(current.subagents, subagents) && - current.subagentObservation === desired.subagentObservation && current.sessionBoundary === desired.sessionBoundary && current.updatedAt === summary.updatedAt && current.terminalTitle === tab.label && @@ -212,10 +138,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }) () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), [environmentId] ) - const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target) + const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target) useEffect(() => { - projectStatus(tab, summary, observation) - }, [summary, observation, tab]) + projectStatus(tab, summary) + }, [summary, tab]) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts index 406a72f7328..5135eea6933 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -29,13 +29,8 @@ export function buildSubagentChildRows(args: { return [] } return subagents.map((subagent) => { - const observation = args.parentEntry.subagentObservation - const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh) - const activeState = fresh && subagent.state !== 'idle' ? subagent.state : undefined - const state = - observation === 'unverifiable' && subagent.state !== 'idle' - ? 'unverifiable' - : (activeState ?? 'idle') + const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined + const state = activeState ?? 'idle' const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) const entry: AgentStatusEntry = { diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index de1617cdb1b..d9cb4dd3d72 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -125,58 +125,6 @@ describe('structured agent session status feed', () => { expect(feed.getSnapshot().get('session-2')?.status).toBe('idle') }) - it('confirms sessions per connection and rejects late events from a disconnected stream', async () => { - const feed = getStructuredAgentSessionStatusFeed(LOCAL) - const deactivate = feed.activate() - await vi.advanceTimersByTimeAsync(0) - hostEmit()({ type: 'snapshot', sessions: [summary('one'), summary('two')] }) - expect(feed.getSessionObservation('one')).toBe('live') - await vi.advanceTimersByTimeAsync(31 * 60_000) - expect(feed.getSessionObservation('one')).toBe('live') - expect(mocks.subscribeStatus).toHaveBeenCalledOnce() - hostEmit()({ type: 'end' }) - expect(feed.getSessionObservation('one')).toBe('unverifiable') - hostEmit()({ type: 'status', session: summary('one') }) - expect(feed.getSessionObservation('one')).toBe('unverifiable') - await vi.advanceTimersByTimeAsync(300) - hostEmit(1)({ type: 'snapshot', sessions: [summary('two')] }) - expect(feed.getSessionObservation('two')).toBe('live') - expect(feed.getSessionObservation('one')).toBe('unverifiable') - expect(feed.getSnapshot().has('one')).toBe(true) - hostEmit(1)({ type: 'status', session: summary('one') }) - expect(feed.getSessionObservation('one')).toBe('live') - deactivate() - expect(feed.getSessionObservation('one')).toBe('unverifiable') - expect(vi.getTimerCount()).toBe(0) - }) - - it.each(['error', 'close', 'reject'] as const)( - 'revokes confirmation on subscription %s', - async (failure) => { - let reject!: (error: Error) => void - mocks.subscribeStatus.mockImplementationOnce( - () => - new Promise((_resolve, rejectPromise) => { - reject = rejectPromise - }) - ) - const feed = getStructuredAgentSessionStatusFeed(LOCAL) - feed.activate() - hostEmit()({ type: 'status', session: summary('one') }) - expect(feed.getSessionObservation('one')).toBe('live') - if (failure === 'reject') { - reject(new Error('disconnected')) - } else { - mocks.subscribeStatus.mock.calls[0][failure === 'error' ? 2 : 3]() - } - await vi.advanceTimersByTimeAsync(0) - expect(feed.getSessionObservation('one')).toBe('unverifiable') - hostEmit()({ type: 'status', session: summary('one') }) - expect(feed.getSessionObservation('one')).toBe('unverifiable') - expect(vi.getTimerCount()).toBe(1) - } - ) - it('stops a pending reconnect when the feeds are reset between tests', async () => { getStructuredAgentSessionStatusFeed(LOCAL).activate() await vi.advanceTimersByTimeAsync(0) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 4a50717f638..8308ac8729a 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -21,7 +21,6 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap () => void getSnapshot: () => StructuredAgentSessionStatusSnapshot - getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void } @@ -38,7 +37,6 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget) function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { let snapshot: StructuredAgentSessionStatusSnapshot = new Map() - const confirmedSessions = new Set() const listeners = new Set<() => void>() const activations = new Set() let generation = 0 @@ -62,14 +60,12 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { // the first snapshot can be empty and dropping those rows flickers every one to no-status. const next = new Map(snapshot) for (const session of event.sessions) { - confirmedSessions.add(session.sessionId) next.set(session.sessionId, session) } setSnapshot(next) return } if (event.type === 'status') { - confirmedSessions.add(event.session.sessionId) const next = new Map(snapshot) next.set(event.session.sessionId, event.session) setSnapshot(next) @@ -103,6 +99,15 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { emit() } } + const fenceCandidateAndReconnect = (candidate: number): void => { + if (candidate !== generation) { + return + } + generation += 1 + revokeSnapshotOwnership() + dropHandle() + scheduleReconnect(generation) + } let open = (): void => {} const scheduleReconnect = (candidate: number): void => { if (!active(candidate) || reconnectTimer) { @@ -117,19 +122,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, delay) } - // Losing contact is never exit: the sessions go unverifiable and this client stops - // claiming host-owned execution, but nothing here settles them. - const loseConnection = (candidate: number): void => { - if (candidate !== generation) { - return - } - generation += 1 - confirmedSessions.clear() - revokeSnapshotOwnership() - emit() - dropHandle() - scheduleReconnect(generation) - } const subscribeToHost = (candidate: number): void => { void subscribeStructuredAgentSessionStatus( target, @@ -138,19 +130,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { - loseConnection(candidate) + fenceCandidateAndReconnect(candidate) return } applyEvent(event) }, () => { if (active(candidate)) { - loseConnection(candidate) + fenceCandidateAndReconnect(candidate) } }, () => { if (active(candidate)) { - loseConnection(candidate) + fenceCandidateAndReconnect(candidate) } } ) @@ -161,7 +153,13 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { opened.unsubscribe() } }) - .catch(() => loseConnection(candidate)) + .catch(() => { + if (active(candidate)) { + fenceCandidateAndReconnect(candidate) + } else { + scheduleReconnect(candidate) + } + }) } open = (): void => { const candidate = ++generation @@ -188,7 +186,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } console.warn('[structured-session-status] host too old for the status feed', environmentId) }) - .catch(() => loseConnection(candidate)) + .catch(() => scheduleReconnect(candidate)) } const stop = (): void => { generation += 1 @@ -196,9 +194,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { dropHandle() revokeSnapshotOwnership() reconnectAttempt = 0 - // Teardown only runs once nothing is activated, so re-confirmation is the next - // subscribe's job and there is no mounted reader left to notify. - confirmedSessions.clear() } return { @@ -216,8 +211,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, getSnapshot: () => snapshot, - getSessionObservation: (sessionId) => - confirmedSessions.has(sessionId) ? 'live' : 'unverifiable', subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index eaab5bb78ae..453fef02139 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -83,7 +83,6 @@ export type AgentLaunchConfigRegistryEntry = { } export type AgentStatusPayload = ParsedAgentStatusPayload & { - subagentObservation?: AgentStatusEntry['subagentObservation'] orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string restoredUnconfirmed?: boolean diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index b161e51bc04..63ba0a5afc9 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -256,7 +256,6 @@ export function buildAgentStatusLiveEntry( lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput, ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), orchestration, - ...(payload.subagentObservation ? { subagentObservation: payload.subagentObservation } : {}), subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) ? existing?.subagents : payload.subagents, diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3fc6c6b7f34..cc0ad487565 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -102,8 +102,6 @@ export type AgentSubagentSnapshot = { } export type AgentStatusEntry = { - /** Renderer-local status-feed confirmation for children; absent on hook rows. */ - subagentObservation?: 'live' | 'unverifiable' state: AgentStatusState /** Ongoing work that does not require foreground agent execution. Only valid while working. */ workingMode?: AgentWorkingMode