mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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 <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
f2af92b2fa
commit
4408fe897a
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,8 @@ import type { DashboardAgentRow } from './useDashboardData'
|
||||
export function lastEnteredDoneAt(
|
||||
agent: Pick<DashboardAgentRow, 'rowSource' | 'state' | 'entry'>
|
||||
): 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
|
||||
|
||||
@@ -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(<StructuredAgentSessionStatusBridge />)
|
||||
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(<StructuredAgentSessionStatusBridge />)
|
||||
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(<StructuredAgentSessionStatusBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,7 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap<string, AgentSess
|
||||
export type StructuredAgentSessionStatusFeedOwner = {
|
||||
activate: () => () => 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<string>()
|
||||
const listeners = new Set<() => void>()
|
||||
const activations = new Set<symbol>()
|
||||
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)
|
||||
|
||||
@@ -83,6 +83,7 @@ export type AgentLaunchConfigRegistryEntry = {
|
||||
}
|
||||
|
||||
export type AgentStatusPayload = ParsedAgentStatusPayload & {
|
||||
subagentObservation?: AgentStatusEntry['subagentObservation']
|
||||
orchestration?: AgentStatusOrchestrationContext
|
||||
promptInteractionKey?: string
|
||||
restoredUnconfirmed?: boolean
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user