fix(tui): handle timeout/error subagent statuses in /agents (#26687)

Accept delegation timeout/error statuses in the TUI subagent model, normalize unknown status strings defensively, and harden /agents overlay rendering/sorting so unknown statuses cannot crash glyph/color lookup. Add regression tests for live event normalization and disk snapshot replay.
This commit is contained in:
brooklyn! 2026-05-15 20:19:02 -05:00 committed by GitHub
parent 566d8f0d75
commit 006937f7d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 173 additions and 14 deletions

View file

@ -737,6 +737,61 @@ describe('createGatewayEventHandler', () => {
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
})
it('accepts timeout/error subagent terminal statuses and ignores stale live events', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'timeout child', status: 'timeout', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')
// Late start/spawn updates must not clobber terminal timeout/error states.
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
type: 'subagent.spawn_requested'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')
onEvent({
payload: { goal: 'error child', subagent_id: 'sa-error', task_index: 1 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'error child', status: 'error', subagent_id: 'sa-error', task_index: 1 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-error')?.status).toBe('error')
})
it('normalizes unknown subagent.complete statuses to completed', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'weird child', subagent_id: 'sa-weird', task_index: 2 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'weird child', status: 'mystery_status', subagent_id: 'sa-weird', task_index: 2 },
type: 'subagent.complete'
} as any)
expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed')
})
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for

View file

@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { clearSpawnHistory, getSpawnHistory, pushDiskSnapshot } from '../app/spawnHistoryStore.js'
describe('spawnHistoryStore status normalization', () => {
beforeEach(() => {
clearSpawnHistory()
})
it('keeps timeout/error statuses from disk snapshots', () => {
pushDiskSnapshot(
{
finished_at: 1_700_000_001,
label: 'status test',
session_id: 'sess-1',
started_at: 1_700_000_000,
subagents: [
{ goal: 'timeout child', id: 'sa-timeout', index: 0, status: 'timeout' },
{ goal: 'error child', id: 'sa-error', index: 1, status: 'error' }
]
},
'/tmp/snap-timeout-error.json'
)
const statuses = getSpawnHistory()[0]?.subagents.map(s => s.status)
expect(statuses).toEqual(['timeout', 'error'])
})
it('falls back unknown disk statuses to completed', () => {
pushDiskSnapshot(
{
finished_at: 1_700_000_011,
label: 'unknown status test',
session_id: 'sess-2',
started_at: 1_700_000_010,
subagents: [{ goal: 'mystery child', id: 'sa-unknown', index: 0, status: 'mystery_status' }]
},
'/tmp/snap-unknown.json'
)
const status = getSpawnHistory()[0]?.subagents[0]?.status
expect(status).toBe('completed')
})
})

View file

@ -13,7 +13,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SubagentProgress } from '../types.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
@ -54,6 +54,26 @@ const pushThinking = pushUnique(6)
const pushNote = pushUnique(6)
const pushTool = pushUnique(8)
const KNOWN_SUBAGENT_STATUSES = new Set<SubagentStatus>([
'completed',
'error',
'failed',
'interrupted',
'queued',
'running',
'timeout'
])
const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => {
if (typeof status !== 'string') {
return fallback
}
const normalized = status.toLowerCase() as SubagentStatus
return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
@ -180,8 +200,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
// terminal state from complete (failed/interrupted/timeout/error).
const isTerminalStatus = (s: SubagentProgress['status']) =>
s === 'completed' || s === 'error' || s === 'failed' || s === 'interrupted' || s === 'timeout'
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
@ -648,7 +669,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
ev.payload,
c => ({
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
status: ev.payload.status ?? 'completed',
status: normalizeSubagentStatus(ev.payload.status, 'completed'),
summary: ev.payload.summary || ev.payload.text || c.summary
}),
{ createIfMissing: false }

View file

@ -1,7 +1,7 @@
import { atom } from 'nanostores'
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
import type { SubagentProgress } from '../types.js'
import type { SubagentProgress, SubagentStatus } from '../types.js'
export interface SpawnSnapshot {
finishedAt: number
@ -21,6 +21,26 @@ export interface SpawnDiffPair {
const HISTORY_LIMIT = 10
const KNOWN_SUBAGENT_STATUSES = new Set<SubagentStatus>([
'completed',
'error',
'failed',
'interrupted',
'queued',
'running',
'timeout'
])
const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => {
if (typeof status !== 'string') {
return fallback
}
const normalized = status.toLowerCase() as SubagentStatus
return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback
}
export const $spawnHistory = atom<SpawnSnapshot[]>([])
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
@ -128,7 +148,7 @@ function normaliseSubagent(raw: unknown): SubagentProgress {
parentId: s(o.parentId) ?? null,
reasoningTokens: n(o.reasoningTokens),
startedAt: n(o.startedAt),
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
status: normalizeSubagentStatus(o.status, 'completed'),
summary: s(o.summary),
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),

View file

@ -57,25 +57,33 @@ const FILTER_LABEL: Record<FilterMode, string> = {
}
const STATUS_RANK: Record<Status, number> = {
error: 0,
failed: 0,
interrupted: 1,
timeout: 1,
running: 2,
queued: 3,
completed: 4
}
const statusRank = (status: string): number => STATUS_RANK[status as Status] ?? STATUS_RANK.error
const SORT_COMPARATORS: Record<SortMode, (a: SubagentNode, b: SubagentNode) => number> = {
'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index,
'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools,
'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration,
status: (a, b) => STATUS_RANK[a.item.status] - STATUS_RANK[b.item.status]
status: (a, b) => statusRank(a.item.status) - statusRank(b.item.status)
}
const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
all: () => true,
leaf: n => n.children.length === 0,
running: n => n.item.status === 'running' || n.item.status === 'queued',
failed: n => n.item.status === 'failed' || n.item.status === 'interrupted'
failed: n =>
n.item.status === 'error' ||
n.item.status === 'failed' ||
n.item.status === 'interrupted' ||
n.item.status === 'timeout'
}
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
@ -83,7 +91,9 @@ const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string
queued: { color: t => t.color.muted, glyph: '○' },
completed: { color: t => t.color.statusGood, glyph: '✓' },
interrupted: { color: t => t.color.warn, glyph: '■' },
failed: { color: t => t.color.error, glyph: '✗' }
failed: { color: t => t.color.error, glyph: '✗' },
timeout: { color: t => t.color.warn, glyph: '⌛' },
error: { color: t => t.color.error, glyph: '⚠' }
}
// Heatmap palette — cold → hot, resolved against the active theme.
@ -111,7 +121,8 @@ const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ')
const cycle = <T,>(order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]!
const statusGlyph = (item: SubagentProgress, t: Theme) => {
const g = STATUS_GLYPH[item.status]
// Defensive fallback for cross-version snapshots with unknown statuses.
const g = STATUS_GLYPH[item.status] ?? STATUS_GLYPH.error
return { color: g.color(t), glyph: g.glyph }
}

View file

@ -327,7 +327,11 @@ function SubagentAccordion({
const aggregate = node.aggregate
const statusTone: 'dim' | 'error' | 'warn' =
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
item.status === 'error' || item.status === 'failed'
? 'error'
: item.status === 'interrupted' || item.status === 'timeout'
? 'warn'
: 'dim'
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
const goalLabel = item.goal || `Subagent ${item.index + 1}`

View file

@ -1,4 +1,4 @@
import type { SessionInfo, SlashCategory, Usage } from './types.js'
import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js'
export interface GatewaySkin {
banner_hero?: string
@ -394,7 +394,7 @@ export interface SubagentEventPayload {
output_tokens?: number
parent_id?: null | string
reasoning_tokens?: number
status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
status?: SubagentStatus
subagent_id?: string
summary?: string
task_count?: number

View file

@ -17,6 +17,8 @@ export interface ActivityItem {
tone: 'error' | 'info' | 'warn'
}
export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout'
export interface SubagentProgress {
apiCalls?: number
costUsd?: number
@ -36,7 +38,7 @@ export interface SubagentProgress {
parentId: null | string
reasoningTokens?: number
startedAt?: number
status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
status: SubagentStatus
summary?: string
taskCount: number
thinking: string[]