feat(codex): refine tool activity display

This commit is contained in:
weishu
2026-07-24 09:39:22 +08:00
parent dd42263aaf
commit 74ad25ec57
28 changed files with 1591 additions and 85 deletions
+5 -1
View File
@@ -59,9 +59,13 @@ const MILESTONE_TOOL_NAMES = new Set([
'Skill',
'spawn_agent',
'send_input',
'send_message',
'resume_agent',
'followup_task',
'wait_agent',
'close_agent'
'close_agent',
'interrupt_agent',
'list_agents'
])
const INTERACTIVE_TOOL_NAMES = new Set([
+100 -20
View File
@@ -4,9 +4,13 @@ import { getInputStringAny, truncate } from '@/lib/toolInputUtils'
export const codexAgentToolNames = [
'spawn_agent',
'send_input',
'send_message',
'resume_agent',
'followup_task',
'wait_agent',
'close_agent'
'close_agent',
'interrupt_agent',
'list_agents'
] as const
export type CodexAgentToolName = typeof codexAgentToolNames[number]
@@ -200,6 +204,9 @@ export function getCodexAgentFieldRows(toolName: string, input: unknown): Array<
const timeout = typeof input.timeout_ms === 'number' ? `${input.timeout_ms} ms` : null
if (timeout) rows.push({ label: 'Timeout', value: timeout })
const pathPrefix = asNonEmptyString(input.path_prefix)
if (pathPrefix) rows.push({ label: 'Path prefix', value: pathPrefix })
}
const targets = getCodexAgentTargets(input)
@@ -216,6 +223,7 @@ export function getCodexAgentFieldRows(toolName: string, input: unknown): Array<
export type CodexSpawnAgentResult = {
agentId: string | null
nickname: string | null
taskName: string | null
}
export function parseCodexSpawnAgentResult(result: unknown): CodexSpawnAgentResult | null {
@@ -224,9 +232,10 @@ export function parseCodexSpawnAgentResult(result: unknown): CodexSpawnAgentResu
const agentId = asNonEmptyString(obj.agent_id) ?? asNonEmptyString(obj.agentId) ?? asNonEmptyString(obj.id)
const nickname = asNonEmptyString(obj.nickname) ?? asNonEmptyString(obj.name)
const taskName = asNonEmptyString(obj.task_name) ?? asNonEmptyString(obj.taskName)
if (!agentId && !nickname) return null
return { agentId, nickname }
if (!agentId && !nickname && !taskName) return null
return { agentId, nickname, taskName }
}
export type CodexAgentStatus = {
@@ -236,10 +245,12 @@ export type CodexAgentStatus = {
}
function extractStatusText(value: unknown): string | null {
if (typeof value === 'string') return value
if (typeof value === 'string') {
return normalizeStatusState(value) ? null : value
}
if (!isObject(value)) return null
const candidates = ['completed', 'failed', 'error', 'message', 'output', 'text', 'reason']
const candidates = ['completed', 'errored', 'failed', 'error', 'message', 'output', 'text', 'reason']
for (const key of candidates) {
const candidate = value[key]
if (typeof candidate === 'string') return candidate
@@ -248,19 +259,51 @@ function extractStatusText(value: unknown): string | null {
return safeStringify(value)
}
const CODEX_AGENT_STATUS_STATES = new Set([
'completed',
'errored',
'failed',
'error',
'canceled',
'cancelled',
'killed',
'running',
'pending',
'pending_init',
'interrupted',
'shutdown',
'not_found'
])
function normalizeStatusState(value: string): string | null {
const state = value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/[\s-]+/g, '_')
if (!CODEX_AGENT_STATUS_STATES.has(state)) return null
if (state === 'cancelled') return 'canceled'
if (state === 'errored') return 'error'
return state
}
function extractStatusState(value: unknown): string {
if (typeof value === 'string') return normalizeStatusState(value) ?? 'completed'
if (!isObject(value)) return 'completed'
const states = ['completed', 'failed', 'error', 'canceled', 'cancelled', 'killed', 'running', 'pending']
for (const state of states) {
if (state in value) return state === 'cancelled' ? 'canceled' : state
for (const state of CODEX_AGENT_STATUS_STATES) {
if (state in value) return normalizeStatusState(state) ?? state
}
const status = asNonEmptyString(value.status) ?? asNonEmptyString(value.state)
return status ?? 'completed'
return status ? normalizeStatusState(status) ?? status : 'completed'
}
export function parseCodexWaitAgentResult(result: unknown): { statuses: CodexAgentStatus[]; timedOut: boolean | null } | null {
export function parseCodexWaitAgentResult(result: unknown): {
statuses: CodexAgentStatus[]
timedOut: boolean | null
message: string | null
} | null {
const obj = parseMaybeJsonObject(result)
if (!obj) return null
@@ -282,19 +325,22 @@ export function parseCodexWaitAgentResult(result: unknown): { statuses: CodexAge
: typeof obj.timedOut === 'boolean'
? obj.timedOut
: null
const message = asNonEmptyString(obj.message)
if (statuses.length === 0 && timedOut === null) return null
return { statuses, timedOut }
if (statuses.length === 0 && timedOut === null && !message) return null
return { statuses, timedOut, message }
}
export function parseCodexCloseAgentResult(result: unknown): CodexAgentStatus | null {
const obj = parseMaybeJsonObject(result)
if (!obj) return null
const previousStatus = isObject(obj.previous_status) ? obj.previous_status
: isObject(obj.previousStatus) ? obj.previousStatus
const previousStatus = Object.prototype.hasOwnProperty.call(obj, 'previous_status')
? obj.previous_status
: Object.prototype.hasOwnProperty.call(obj, 'previousStatus')
? obj.previousStatus
: null
if (!previousStatus) return null
if (previousStatus === null || previousStatus === undefined) return null
return {
agentId: '',
@@ -303,13 +349,36 @@ export function parseCodexCloseAgentResult(result: unknown): CodexAgentStatus |
}
}
export function parseCodexListAgentsResult(result: unknown): CodexAgentStatus[] | null {
const obj = parseMaybeJsonObject(result)
if (!obj || !Array.isArray(obj.agents)) return null
const agents = obj.agents.flatMap((value): CodexAgentStatus[] => {
if (!isObject(value)) return []
const agentId = asNonEmptyString(value.agent_name)
?? asNonEmptyString(value.agentName)
?? asNonEmptyString(value.agent_id)
?? asNonEmptyString(value.agentId)
if (!agentId) return []
const status = value.agent_status ?? value.agentStatus ?? value.status
return [{
agentId,
state: extractStatusState(status),
text: extractStatusText(status)
}]
})
return agents
}
export function summarizeCodexAgentResult(toolName: string, result: unknown): string | null {
if (toolName === 'spawn_agent') {
const parsed = parseCodexSpawnAgentResult(result)
if (!parsed) return null
const label = parsed.nickname && parsed.agentId
? `${parsed.nickname} (${parsed.agentId})`
: parsed.nickname ?? parsed.agentId
const reference = parsed.taskName ?? parsed.agentId
const label = parsed.nickname && reference
? `${parsed.nickname} (${reference})`
: parsed.nickname ?? reference
return label ? `Launched ${label}` : 'Agent launched'
}
@@ -317,6 +386,7 @@ export function summarizeCodexAgentResult(toolName: string, result: unknown): st
const parsed = parseCodexWaitAgentResult(result)
if (!parsed) return null
if (parsed.statuses.length === 0 && parsed.timedOut) return 'Timed out'
if (parsed.statuses.length === 0 && parsed.message) return parsed.message
const completed = parsed.statuses.filter((status) => status.state === 'completed').length
const failed = parsed.statuses.filter((status) => status.state !== 'completed').length
const parts = []
@@ -326,10 +396,20 @@ export function summarizeCodexAgentResult(toolName: string, result: unknown): st
return parts.length > 0 ? parts.join(', ') : 'No agent status yet'
}
if (toolName === 'close_agent') {
if (toolName === 'close_agent' || toolName === 'interrupt_agent') {
const parsed = parseCodexCloseAgentResult(result)
if (!parsed) return null
return `Closed (${parsed.state})`
return `${toolName === 'interrupt_agent' ? 'Interrupted' : 'Closed'} (${parsed.state})`
}
if (toolName === 'list_agents') {
const agents = parseCodexListAgentsResult(result)
if (!agents) return null
if (agents.length === 0) return 'No live agents'
const running = agents.filter((agent) => agent.state === 'running').length
return running > 0
? `${agents.length} live, ${running} running`
: `${agents.length} live agent${agents.length === 1 ? '' : 's'}`
}
return null
@@ -214,6 +214,88 @@ describe('getToolPresentation — Codex agent tools', () => {
expect(presentation.subtitle).not.toContain('hidden child output')
expect(presentation.minimal).toBe(true)
})
it('presents MultiAgent V2 messaging tools by intent', () => {
const message = getToolPresentation({
toolName: 'send_message',
input: { target: '/root/review', message: 'Status?' },
result: '',
childrenCount: 0,
description: null,
metadata: null,
})
const followup = getToolPresentation({
toolName: 'followup_task',
input: { target: '/root/review', message: 'Run tests' },
result: '',
childrenCount: 0,
description: null,
metadata: null,
})
expect(message).toMatchObject({ title: 'Message agent', subtitle: '/root/review', minimal: true })
expect(followup).toMatchObject({ title: 'Follow up agent', subtitle: '/root/review', minimal: true })
})
it('summarizes list_agents and interrupt_agent results', () => {
const list = getToolPresentation({
toolName: 'list_agents',
input: {},
result: JSON.stringify({
agents: [
{ agent_name: '/root/a', agent_status: 'running' },
{ agent_name: '/root/b', agent_status: { completed: 'done' } }
]
}),
childrenCount: 0,
description: null,
metadata: null,
})
const interrupt = getToolPresentation({
toolName: 'interrupt_agent',
input: { target: '/root/a' },
result: '{"previous_status":"running"}',
childrenCount: 0,
description: null,
metadata: null,
})
expect(list).toMatchObject({ title: 'List agents', subtitle: '2 live, 1 running' })
expect(interrupt).toMatchObject({ title: 'Interrupt agent', subtitle: 'Interrupted (running)' })
})
it('uses MultiAgent V2 result fields and status variants', () => {
const spawn = getToolPresentation({
toolName: 'spawn_agent',
input: { task_name: 'review' },
result: JSON.stringify({ task_name: '/root/review', nickname: 'Reviewer' }),
childrenCount: 0,
description: null,
metadata: null,
})
const wait = getToolPresentation({
toolName: 'wait_agent',
input: { timeout_ms: 1000 },
result: JSON.stringify({ message: 'Wait completed.', timed_out: false }),
childrenCount: 0,
description: null,
metadata: null,
})
const list = getToolPresentation({
toolName: 'list_agents',
input: {},
result: JSON.stringify({
agents: [{ agent_name: '/root/review', agent_status: { errored: 'test failed' } }]
}),
childrenCount: 0,
description: null,
metadata: null,
})
expect(spawn.subtitle).toBe('Launched Reviewer (/root/review)')
expect(wait.subtitle).toBe('Wait completed.')
expect(list.subtitle).toBe('1 live agent')
})
})
describe('getToolPresentation — native titles', () => {
@@ -355,6 +355,15 @@ export const knownTools: Record<string, {
},
minimal: true
},
send_message: {
icon: () => <MessageSquareIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Message agent',
subtitle: (opts) => {
const targets = getCodexAgentTargets(opts.input)
return targets.length > 0 ? targets.join(', ') : 'Queued message'
},
minimal: true
},
resume_agent: {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Resume agent',
@@ -364,6 +373,15 @@ export const knownTools: Record<string, {
},
minimal: true
},
followup_task: {
icon: () => <MessageSquareIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Follow up agent',
subtitle: (opts) => {
const targets = getCodexAgentTargets(opts.input)
return targets.length > 0 ? targets.join(', ') : null
},
minimal: true
},
wait_agent: {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
@@ -389,6 +407,24 @@ export const knownTools: Record<string, {
},
minimal: true
},
interrupt_agent: {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Interrupt agent',
subtitle: (opts) => {
const summary = summarizeCodexAgentResult(opts.toolName, opts.result)
if (summary) return summary
const targets = getCodexAgentTargets(opts.input)
return targets.length > 0 ? targets.join(', ') : null
},
minimal: true
},
list_agents: {
icon: () => <UsersIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'List agents',
subtitle: (opts) => summarizeCodexAgentResult(opts.toolName, opts.result)
?? getInputStringAny(opts.input, ['path_prefix']),
minimal: true
},
CodexReasoning: {
icon: () => <BulbIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => getInputStringAny(opts.input, ['title']) ?? 'Reasoning',
@@ -84,9 +84,13 @@ export const toolViewRegistry: Record<string, ToolViewComponent> = {
CodexAgent: CodexAgentView,
spawn_agent: CodexAgentView,
send_input: CodexAgentView,
send_message: CodexAgentView,
resume_agent: CodexAgentView,
followup_task: CodexAgentView,
wait_agent: CodexAgentView,
close_agent: CodexAgentView,
interrupt_agent: CodexAgentView,
list_agents: CodexAgentView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
CursorAskQuestion: AskUserQuestionView,
@@ -106,9 +110,13 @@ export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
Skill: SkillFullView,
spawn_agent: CodexAgentView,
send_input: CodexAgentView,
send_message: CodexAgentView,
resume_agent: CodexAgentView,
followup_task: CodexAgentView,
wait_agent: CodexAgentView,
close_agent: CodexAgentView,
interrupt_agent: CodexAgentView,
list_agents: CodexAgentView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
CursorAskQuestion: AskUserQuestionView,
@@ -153,6 +153,9 @@ describe('getToolResultViewComponent registry', () => {
it('uses a dedicated result view for Codex agent tools', () => {
expect(getToolResultViewComponent('spawn_agent')).not.toBe(getToolResultViewComponent('SomeUnknownTool'))
expect(getToolResultViewComponent('wait_agent')).toBe(getToolResultViewComponent('spawn_agent'))
expect(getToolResultViewComponent('followup_task')).toBe(getToolResultViewComponent('spawn_agent'))
expect(getToolResultViewComponent('interrupt_agent')).toBe(getToolResultViewComponent('spawn_agent'))
expect(getToolResultViewComponent('list_agents')).toBe(getToolResultViewComponent('spawn_agent'))
})
it('Agent falls back to GenericResultView (no dedicated view — view layer must not filter content)', () => {
+42 -3
View File
@@ -10,6 +10,7 @@ import {
getCodexAgentActivity,
getCodexAgentTargets,
parseCodexCloseAgentResult,
parseCodexListAgentsResult,
parseCodexSpawnAgentResult,
parseCodexWaitAgentResult
} from '@/components/ToolCard/codexAgents'
@@ -791,6 +792,7 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => {
<div className="flex flex-wrap gap-2">
<ResultStatusPill text="Agent launched" />
{parsed.nickname ? <AgentIdPill label="Name" value={parsed.nickname} /> : null}
{parsed.taskName ? <AgentIdPill label="Task" value={parsed.taskName} /> : null}
{parsed.agentId ? <AgentIdPill label="ID" value={parsed.agentId} /> : null}
{showDetails ? <RawJsonDevOnly value={result} surface={props.surface} /> : null}
</div>
@@ -802,7 +804,7 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => {
const parsed = parseCodexWaitAgentResult(result)
if (parsed) {
if (parsed.statuses.length === 0) {
return <ResultStatusPill text={parsed.timedOut ? 'Timed out' : 'No status'} />
return <ResultStatusPill text={parsed.timedOut ? 'Timed out' : parsed.message ?? 'No status'} />
}
return (
@@ -840,14 +842,14 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => {
}
}
if (name === 'close_agent') {
if (name === 'close_agent' || name === 'interrupt_agent') {
const parsed = parseCodexCloseAgentResult(result)
if (parsed) {
const targets = getCodexAgentTargets(input)
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<ResultStatusPill text="Agent closed" />
<ResultStatusPill text={name === 'interrupt_agent' ? 'Agent interrupted' : 'Agent closed'} />
{targets[0] ? <AgentIdPill label="ID" value={targets[0]} /> : null}
<ResultStatusPill text={parsed.state} />
</div>
@@ -862,6 +864,39 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => {
}
}
if (name === 'list_agents') {
const agents = parseCodexListAgentsResult(result)
if (agents) {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<ResultStatusPill text={`${agents.length} live agent${agents.length === 1 ? '' : 's'}`} />
{Object.entries(agents.reduce<Record<string, number>>((counts, agent) => {
counts[agent.state] = (counts[agent.state] ?? 0) + 1
return counts
}, {})).map(([status, count]) => (
<ResultStatusPill key={status} text={`${count} ${status}`} />
))}
</div>
{showDetails ? (
<div className="flex flex-col gap-2">
{agents.map((agent) => (
<div key={agent.agentId} className="border-b border-[var(--app-border)] py-2 last:border-b-0">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--app-hint)]">
<ResultStatusPill text={agent.state} />
<span className="font-mono break-all">{agent.agentId}</span>
</div>
{agent.text ? <div className="mt-1 text-sm text-[var(--app-fg)]">{agent.text}</div> : null}
</div>
))}
</div>
) : null}
{showDetails ? <RawJsonDevOnly value={result} surface={props.surface} /> : null}
</div>
)
}
}
const text = extractTextFromResult(result)
if (text) {
if (!showDetails) {
@@ -980,9 +1015,13 @@ export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
Skill: SkillResultView,
spawn_agent: CodexAgentResultView,
send_input: CodexAgentResultView,
send_message: CodexAgentResultView,
resume_agent: CodexAgentResultView,
followup_task: CodexAgentResultView,
wait_agent: CodexAgentResultView,
close_agent: CodexAgentResultView,
interrupt_agent: CodexAgentResultView,
list_agents: CodexAgentResultView,
AskUserQuestion: AskUserQuestionResultView,
ExitPlanMode: MarkdownResultView,
ask_user_question: AskUserQuestionResultView,