mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
566d8f0d75
commit
006937f7d0
8 changed files with 173 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
46
ui-tui/src/__tests__/spawnHistoryStore.test.ts
Normal file
46
ui-tui/src/__tests__/spawnHistoryStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue