Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/desktop/src/store/subagents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,96 @@ describe('subagent store', () => {
.sort()
).toEqual(['c', 'd'])
})

// Regression test for #73728: backend terminal statuses like `timeout` and
// `error` were normalised to `running`, making timed-out subagents immortal
// in the active status stack. `cancelled`/`canceled` must also map to
// `interrupted`.
it('normalises backend terminal statuses to recognised SubagentStatus values', () => {
upsertSubagent('s1', { goal: 'a', status: 'running', subagent_id: 'a', task_index: 0 })
upsertSubagent('s1', { goal: 'b', status: 'running', subagent_id: 'b', task_index: 1 })
upsertSubagent('s1', { goal: 'c', status: 'running', subagent_id: 'c', task_index: 2 })
upsertSubagent('s1', { goal: 'd', status: 'running', subagent_id: 'd', task_index: 3 })

// Emit terminal events with backend-native status strings
upsertSubagent('s1', { status: 'timeout', subagent_id: 'a', task_index: 0, summary: 'timed out' }, false, 'subagent.complete')
upsertSubagent('s1', { status: 'error', subagent_id: 'b', task_index: 1, summary: 'errored' }, false, 'subagent.complete')
upsertSubagent('s1', { status: 'cancelled', subagent_id: 'c', task_index: 2 }, false, 'subagent.complete')
upsertSubagent('s1', { status: 'canceled', subagent_id: 'd', task_index: 3 }, false, 'subagent.complete')

const items = listFor('s1')
const byId = Object.fromEntries(items.map(i => [i.id, i]))

// timeout → failed
expect(byId['a']?.status).toBe('failed')
expect(byId['a']?.currentTool).toBeUndefined()

// error → failed
expect(byId['b']?.status).toBe('failed')

// cancelled → interrupted
expect(byId['c']?.status).toBe('interrupted')

// canceled → interrupted
expect(byId['d']?.status).toBe('interrupted')

// All four are terminal — prune should remove them all
pruneFinishedSessionSubagents('s1')
expect(listFor('s1')).toHaveLength(0)
})

// The backend completes subagents with status "timeout" (hard child timeout,
// delegation.child_timeout_seconds) and no summary — synthesize the reason
// so the failed row explains itself instead of rendering as a bare failure.
it('maps backend timeout status to a terminal failure with a synthesized reason', () => {
upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 't1', task_index: 0 })
upsertSubagent(
's1',
{ status: 'timeout', subagent_id: 't1', task_index: 0, duration_seconds: 612.3 },
false,
'subagent.complete'
)

const item = listFor('s1')[0]
expect(item?.status).toBe('failed')
expect(item?.durationSeconds).toBe(612.3)
expect(item?.summary).toBe('Timed out after 612.3s')

// A timed-out row must be pruned at the next message.start boundary like
// any other finished row — it must not linger as a live spinner.
pruneFinishedSessionSubagents('s1')
expect(listFor('s1')).toHaveLength(0)
})

it('falls back to a placeholder when timeout duration is missing', () => {
upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 't2', task_index: 0 })
upsertSubagent('s1', { status: 'timeout', subagent_id: 't2', task_index: 0 }, false, 'subagent.complete')

expect(listFor('s1')[0]?.summary).toBe('Timed out after ?s')
})

// Fail-closed guard: subagent.complete is terminal by definition, so an
// unrecognized status on it must not resurrect a row as 'running'. Live
// events keep the lenient fallback (a status we don't know is still active).
it('fails closed on unrecognized completion statuses but stays lenient for live events', () => {
upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'u1', task_index: 0 })
upsertSubagent(
's1',
{ status: 'some_future_terminal_status', subagent_id: 'u1', task_index: 0 },
false,
'subagent.complete'
)
expect(listFor('s1')[0]?.status).toBe('failed')
expect(activeSubagentCount(listFor('s1'))).toBe(0)

upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'u2', task_index: 1 })
upsertSubagent(
's1',
{ status: 'some_future_live_status', subagent_id: 'u2', task_index: 1, text: 'still working' },
false,
'subagent.progress'
)
expect(listFor('s1')[1]?.status).toBe('running')
expect(activeSubagentCount(listFor('s1'))).toBe(1)
})
})
40 changes: 35 additions & 5 deletions apps/desktop/src/store/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,29 @@ const str = (v: unknown) => (isStr(v) ? v : '')
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : [])

const asStatus = (v: unknown): SubagentStatus =>
v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running'
const asStatus = (v: unknown, terminalEvent = false): SubagentStatus => {
if (v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued') {
return v
}

if (v === 'timeout' || v === 'error') {
return 'failed'
}

if (v === 'cancelled' || v === 'canceled') {
return 'interrupted'
}

// Fail closed on completion: a subagent.complete event is terminal by
// definition, so an unrecognized status must render as a failure rather
// than leave a dead row spinning as 'running' forever. Live events keep
// the lenient 'running' fallback.
if (terminalEvent) {
return 'failed'
}

return 'running'
}

const compact = (text: string, max = PREVIEW_MAX) => {
const line = text.replace(/\s+/g, ' ').trim()
Expand Down Expand Up @@ -106,6 +127,15 @@ const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry)
return [...stream, entry].slice(-MAX_STREAM)
}

// The backend sends no summary on a hard child timeout (only a preview like
// "Timed out after 612.3s" + duration_seconds). Synthesize it so the terminal
// row explains why it failed instead of rendering as a bare failure.
const timeoutSummary = (payload: SubagentPayload): string => {
const seconds = num(payload.duration_seconds)

return str(payload.status) === 'timeout' ? `Timed out after ${seconds ?? '?'}s` : ''
}

function streamFromPayload(
payload: SubagentPayload,
status: SubagentStatus,
Expand Down Expand Up @@ -137,7 +167,7 @@ function streamFromPayload(
out.push({ at, kind: 'thinking', text })
}

const summary = compact(str(payload.summary) || str(payload.text))
const summary = compact(str(payload.summary) || str(payload.text) || timeoutSummary(payload))

if (TERMINAL.has(status) && summary) {
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
Expand All @@ -148,7 +178,7 @@ function streamFromPayload(

function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress {
const at = Date.now()
const status = asStatus(payload.status)
const status = asStatus(payload.status, eventType === 'subagent.complete')
const tool = str(payload.tool_name)
const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? [])
const filesRead = strList(payload.files_read)
Expand All @@ -173,7 +203,7 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined
filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []),
filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []),
stream,
summary: str(payload.summary) || prev?.summary,
summary: str(payload.summary) || timeoutSummary(payload) || prev?.summary || undefined,
currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool
}
}
Expand Down
Loading