mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-24 05:41:40 +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' }])
|
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', () => {
|
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
|
// 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
|
// 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 { topLevelSubagents } from '../lib/subagentTree.js'
|
||||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||||
import { fromSkin } from '../theme.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 { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||||
|
|
@ -54,6 +54,26 @@ const pushThinking = pushUnique(6)
|
||||||
const pushNote = pushUnique(6)
|
const pushNote = pushUnique(6)
|
||||||
const pushTool = pushUnique(8)
|
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 {
|
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
|
||||||
const { rpc } = ctx.gateway
|
const { rpc } = ctx.gateway
|
||||||
const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
|
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 —
|
// Terminal statuses are never overwritten by late-arriving live events —
|
||||||
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
||||||
// `failed` or `interrupted` terminal state (Copilot review #14045).
|
// terminal state from complete (failed/interrupted/timeout/error).
|
||||||
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
|
const isTerminalStatus = (s: SubagentProgress['status']) =>
|
||||||
|
s === 'completed' || s === 'error' || s === 'failed' || s === 'interrupted' || s === 'timeout'
|
||||||
|
|
||||||
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
|
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
|
||||||
|
|
||||||
|
|
@ -648,7 +669,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
ev.payload,
|
ev.payload,
|
||||||
c => ({
|
c => ({
|
||||||
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
|
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
|
summary: ev.payload.summary || ev.payload.text || c.summary
|
||||||
}),
|
}),
|
||||||
{ createIfMissing: false }
|
{ createIfMissing: false }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
||||||
import type { SubagentProgress } from '../types.js'
|
import type { SubagentProgress, SubagentStatus } from '../types.js'
|
||||||
|
|
||||||
export interface SpawnSnapshot {
|
export interface SpawnSnapshot {
|
||||||
finishedAt: number
|
finishedAt: number
|
||||||
|
|
@ -21,6 +21,26 @@ export interface SpawnDiffPair {
|
||||||
|
|
||||||
const HISTORY_LIMIT = 10
|
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 $spawnHistory = atom<SpawnSnapshot[]>([])
|
||||||
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
||||||
|
|
||||||
|
|
@ -128,7 +148,7 @@ function normaliseSubagent(raw: unknown): SubagentProgress {
|
||||||
parentId: s(o.parentId) ?? null,
|
parentId: s(o.parentId) ?? null,
|
||||||
reasoningTokens: n(o.reasoningTokens),
|
reasoningTokens: n(o.reasoningTokens),
|
||||||
startedAt: n(o.startedAt),
|
startedAt: n(o.startedAt),
|
||||||
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
|
status: normalizeSubagentStatus(o.status, 'completed'),
|
||||||
summary: s(o.summary),
|
summary: s(o.summary),
|
||||||
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
||||||
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
|
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> = {
|
const STATUS_RANK: Record<Status, number> = {
|
||||||
|
error: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
interrupted: 1,
|
interrupted: 1,
|
||||||
|
timeout: 1,
|
||||||
running: 2,
|
running: 2,
|
||||||
queued: 3,
|
queued: 3,
|
||||||
completed: 4
|
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> = {
|
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,
|
'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,
|
'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools,
|
||||||
'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration,
|
'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> = {
|
const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
|
||||||
all: () => true,
|
all: () => true,
|
||||||
leaf: n => n.children.length === 0,
|
leaf: n => n.children.length === 0,
|
||||||
running: n => n.item.status === 'running' || n.item.status === 'queued',
|
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 }> = {
|
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: '○' },
|
queued: { color: t => t.color.muted, glyph: '○' },
|
||||||
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
||||||
interrupted: { color: t => t.color.warn, 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.
|
// 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 cycle = <T,>(order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]!
|
||||||
|
|
||||||
const statusGlyph = (item: SubagentProgress, t: Theme) => {
|
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 }
|
return { color: g.color(t), glyph: g.glyph }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,11 @@ function SubagentAccordion({
|
||||||
const aggregate = node.aggregate
|
const aggregate = node.aggregate
|
||||||
|
|
||||||
const statusTone: 'dim' | 'error' | 'warn' =
|
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 prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
|
||||||
const goalLabel = item.goal || `Subagent ${item.index + 1}`
|
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 {
|
export interface GatewaySkin {
|
||||||
banner_hero?: string
|
banner_hero?: string
|
||||||
|
|
@ -394,7 +394,7 @@ export interface SubagentEventPayload {
|
||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
parent_id?: null | string
|
parent_id?: null | string
|
||||||
reasoning_tokens?: number
|
reasoning_tokens?: number
|
||||||
status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
status?: SubagentStatus
|
||||||
subagent_id?: string
|
subagent_id?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
task_count?: number
|
task_count?: number
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export interface ActivityItem {
|
||||||
tone: 'error' | 'info' | 'warn'
|
tone: 'error' | 'info' | 'warn'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout'
|
||||||
|
|
||||||
export interface SubagentProgress {
|
export interface SubagentProgress {
|
||||||
apiCalls?: number
|
apiCalls?: number
|
||||||
costUsd?: number
|
costUsd?: number
|
||||||
|
|
@ -36,7 +38,7 @@ export interface SubagentProgress {
|
||||||
parentId: null | string
|
parentId: null | string
|
||||||
reasoningTokens?: number
|
reasoningTokens?: number
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
status: SubagentStatus
|
||||||
summary?: string
|
summary?: string
|
||||||
taskCount: number
|
taskCount: number
|
||||||
thinking: string[]
|
thinking: string[]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue