From 4408fe897a366005d1846e94cf2cd0a6ad65ee2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:29:30 -0700 Subject: [PATCH] feat(sidebar): show native-chat subagents as sidebar child rows, like CLI agents already do (#19807) * feat(sidebar): indent native-chat subagents under their session row Stacked on #19311, which adds the background-task channel this reads. The bridge maps agent-kind background tasks into AgentStatusEntry.subagents, and the renderer status feed confirms per connection so a reconnect cannot leave a child asserting live from a stream that ended. * fix(sidebar): avoid completed age for unverifiable subagents * fix(sidebar): preserve unverifiable child verdicts --------- Co-authored-by: Merge Sim --- .../dashboard-agent-status-patch.test.ts | 17 +++ .../dashboard-agent-status-patch.ts | 3 +- .../agent-finished-timestamp.test.ts | 8 ++ .../dashboard/agent-finished-timestamp.ts | 5 +- ...tructuredAgentSessionStatusBridge.test.tsx | 128 ++++++++++++++++++ .../StructuredAgentSessionStatusBridge.tsx | 89 +++++++++++- .../sidebar/worktree-subagent-child-rows.ts | 13 +- ...ructured-agent-session-status-feed.test.ts | 52 +++++++ .../structured-agent-session-status-feed.ts | 47 ++++--- .../src/store/slices/agent-status-contract.ts | 1 + .../slices/agent-status-live-entry-builder.ts | 1 + src/shared/agent-status-types.test.ts | 2 + src/shared/agent-status-types.ts | 7 +- 13 files changed, 338 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts index 1d2de1e3624..62ff4b88568 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -99,6 +99,23 @@ describe('patchDashboardSnapshotFromAgentStatus', () => { }) }) + it('keeps an unverifiable child compatible with the pop-out wire vocabulary', () => { + const result = patchDashboardSnapshotFromAgentStatus( + snapshot(), + event({ + subagents: [{ id: 'child-1', state: 'unverifiable', startedAt: 100 }] + }) + ) + + expect(result.snapshot.cards[0].subagents).toEqual([ + { + id: 'tab-1:leaf-1\u0000subagent:child-1', + name: 'unknown', + dotState: 'idle' + } + ]) + }) + it('ignores stale, wrong-workspace, and session-only events', () => { const original = snapshot() expect( diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index 33ac5a1f737..b4f67ef3f32 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -6,6 +6,7 @@ import { type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' +import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket' export type DashboardAgentStatusPatchResult = { matched: boolean @@ -22,7 +23,7 @@ function patchedSubagents( return event.subagents.map((subagent) => ({ id: `${card.paneKey}\u0000subagent:${subagent.id}`, name: subagent.description || subagent.agentType || 'unknown', - dotState: subagent.state + dotState: dashboardCardDotState(subagent.state) })) } diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 84116333a1c..91b445dd78f 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -52,3 +52,11 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { expect(agentEntryCompletionAt(entry)).toBeNull() }) }) + +describe('lastEnteredDoneAt subagent rows', () => { + it('does not report a synthetic completion when the child is unverifiable', () => { + const entry = doneEntry() + + expect(lastEnteredDoneAt({ rowSource: 'subagent', state: 'unverifiable', entry })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 599a07d118a..fe101862957 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -10,9 +10,8 @@ import type { DashboardAgentRow } from './useDashboardData' export function lastEnteredDoneAt( agent: Pick ): number | null { - // Why: idle subagent child rows are alive-but-idle (teammates persist - // between turns), not finished — fall through to the started-at timestamp. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { + // 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 } const entry = agent.entry diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 1276287aca0..b82f2418514 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -6,6 +6,7 @@ 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' @@ -219,6 +220,133 @@ 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' })] + }) + ]) + + act(() => + feed().emit({ + type: 'status', + session: summary({ + updatedAt: 3, + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'unverifiable' }] + }) + }) + ) + expect( + buildSubagentChildRows({ + parentEntry: statuses()[0], + tab: structuredTab as never, + parentIsFresh: true + })[0]?.state + ).toBe('unverifiable') + + // A summary without tasks ends the fan-out: children clear with it. + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 4 }) })) + 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 47d6aec460f..1017c5e87b6 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,7 +1,16 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' -import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +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 { structuredAgentSessionPaneKey, structuredAgentSessionStatusState @@ -49,17 +58,79 @@ export function getStructuredAgentSessionTabs( function useStructuredAgentSessionStatusSummary( sessionId: string, target: RuntimeClientTarget -): AgentSessionStatusSummary | null { +): { summary: AgentSessionStatusSummary | null; observation: 'live' | 'unverifiable' } { const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) useEffect(() => feed.activate(), [feed]) - return useSyncExternalStore( + const summary = useSyncExternalStore( feed.subscribe, () => feed.getSnapshot().get(sessionId) ?? null, () => null ) + const observation = useSyncExternalStore( + feed.subscribe, + () => feed.getSessionObservation(sessionId), + () => 'unverifiable' as const + ) + return { summary, observation } } -function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void { +/** 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': + return 'idle' + case 'unverifiable': + return 'unverifiable' + // 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 { 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. @@ -69,6 +140,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | } 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), @@ -80,6 +152,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | ...(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] @@ -92,6 +165,8 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | 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 && @@ -138,10 +213,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }) () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), [environmentId] ) - const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target) + const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target) useEffect(() => { - projectStatus(tab, summary) - }, [summary, tab]) + projectStatus(tab, summary, observation) + }, [summary, observation, 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 5135eea6933..a140fc30e85 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -29,8 +29,17 @@ export function buildSubagentChildRows(args: { return [] } return subagents.map((subagent) => { - const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined - const state = activeState ?? 'idle' + const observation = args.parentEntry.subagentObservation + const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh) + const activeState = + fresh && subagent.state !== 'idle' && subagent.state !== 'unverifiable' + ? subagent.state + : undefined + const state = + subagent.state === 'unverifiable' || + (observation === 'unverifiable' && subagent.state !== 'idle') + ? 'unverifiable' + : (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 d9cb4dd3d72..de1617cdb1b 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,6 +125,58 @@ 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 8308ac8729a..4a50717f638 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -21,6 +21,7 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap () => void getSnapshot: () => StructuredAgentSessionStatusSnapshot + getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void } @@ -37,6 +38,7 @@ 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 @@ -60,12 +62,14 @@ 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) @@ -99,15 +103,6 @@ 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) { @@ -122,6 +117,19 @@ 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, @@ -130,19 +138,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) return } applyEvent(event) }, () => { if (active(candidate)) { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) } }, () => { if (active(candidate)) { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) } } ) @@ -153,13 +161,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { opened.unsubscribe() } }) - .catch(() => { - if (active(candidate)) { - fenceCandidateAndReconnect(candidate) - } else { - scheduleReconnect(candidate) - } - }) + .catch(() => loseConnection(candidate)) } open = (): void => { const candidate = ++generation @@ -186,7 +188,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } console.warn('[structured-session-status] host too old for the status feed', environmentId) }) - .catch(() => scheduleReconnect(candidate)) + .catch(() => loseConnection(candidate)) } const stop = (): void => { generation += 1 @@ -194,6 +196,9 @@ 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 { @@ -211,6 +216,8 @@ 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 453fef02139..eaab5bb78ae 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -83,6 +83,7 @@ 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 63ba0a5afc9..b161e51bc04 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,6 +256,7 @@ 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.test.ts b/src/shared/agent-status-types.test.ts index f042921fc04..b075f87ca86 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -546,6 +546,7 @@ Fix dispatch fallback preview for normalized status prompts` subagents: [ { id: 'a1', state: 'working', startedAt: 100, agentType: 'general-purpose' }, { id: 'r1', state: 'idle', startedAt: 'nope', description: 'line\none' }, + { id: 'u1', state: 'unverifiable', startedAt: 200 }, { id: '', state: 'working', startedAt: 1 }, { id: 'bad-state', state: 'running', startedAt: 1 }, 'garbage', @@ -572,6 +573,7 @@ Fix dispatch fallback preview for normalized status prompts` startedAt: 0, description: 'line one' }) + expect(result?.subagents?.[2]).toMatchObject({ id: 'u1', state: 'unverifiable' }) }) it('omits subagents when absent or empty', () => { diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index cc0ad487565..666cedb5748 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -85,7 +85,7 @@ export type AgentStatusOrchestrationContext = { attention?: OrchestrationFleetAttention } -export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' +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. */ @@ -102,6 +102,8 @@ 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 @@ -302,7 +304,8 @@ function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null obj.state !== 'working' && obj.state !== 'blocked' && obj.state !== 'waiting' && - obj.state !== 'idle' + obj.state !== 'idle' && + obj.state !== 'unverifiable' ) { return null }