fix(web): align Agent tool dialog with TUI ctrl+o expand (#585)

This commit is contained in:
Junmo Kim
2026-05-07 08:28:03 +08:00
committed by GitHub
parent 08d3d9e111
commit e17d7e5995
15 changed files with 325 additions and 78 deletions
+1
View File
@@ -28,6 +28,7 @@ const STANDARD_TOOLS: Record<string, string> = {
'TodoWrite': 'Update Tasks',
'TodoRead': 'Read Tasks',
'Task': 'Launch Agent',
'Agent': 'Launch Agent',
// Team management
'TeamCreate': 'Create Team',
+1
View File
@@ -101,6 +101,7 @@ function formatToolArgumentsDetailed(tool: string, args: any): string {
return `Command: ${truncate(cmd, MAX_TOOL_ARGS_LENGTH)}`
}
case 'Agent':
case 'Task': {
const desc = args.description || args.prompt || ''
return `Task: ${truncate(desc, MAX_TOOL_ARGS_LENGTH)}`
+82
View File
@@ -324,6 +324,88 @@ describe('reduceTimeline', () => {
expect(toolBlock.invokedAt).toBe(1_700_000_000_500)
})
it('populates block.children for Agent tool (same as Task)', () => {
// Agent tool_use message with a sidechain group
const agentToolMsg: TracedMessage = {
id: 'msg-agent',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{
type: 'tool-call',
id: 'tc-agent-1',
name: 'Agent',
input: { prompt: 'explore stuff', subagent_type: 'general-purpose' },
description: null,
uuid: 'u-agent',
parentUUID: null
}],
isSidechain: false
} as TracedMessage
// Sidechain child message that would be in the group for msg-agent
const sidechainChild: TracedMessage = {
id: 'sc-msg-1',
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
content: [{
type: 'tool-call',
id: 'tc-glob-1',
name: 'Glob',
input: { pattern: '**/*.ts' },
description: null,
uuid: 'u-sc-1',
parentUUID: null
}],
isSidechain: true,
sidechainId: 'msg-agent'
} as TracedMessage
// Build groups map the way the real pipeline does it
const groups = new Map<string, TracedMessage[]>()
groups.set('msg-agent', [sidechainChild])
const ctx = { ...makeContext(), groups }
const { blocks } = reduceTimeline([agentToolMsg], ctx)
const agentBlock = blocks.find(b => b.kind === 'tool-call') as any
expect(agentBlock).toBeDefined()
// block.children must be populated for Agent (was broken before fix)
expect(agentBlock.children.length).toBeGreaterThan(0)
})
it('suppresses prompt-text duplicate for Agent tool (same as Task)', () => {
// When an agent message contains an Agent tool_use, Claude often writes
// the prompt as a text block before the tool_use. The reducer must skip
// that duplicate text just like it does for Task.
const prompt = 'explore the repository structure'
const agentMsg: TracedMessage = {
id: 'msg-agent-dup',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [
{ type: 'text', text: prompt, uuid: 'u-text', parentUUID: null },
{
type: 'tool-call',
id: 'tc-agent-2',
name: 'Agent',
input: { prompt, subagent_type: 'Explore' },
description: null,
uuid: 'u-agent',
parentUUID: null
}
],
isSidechain: false
} as TracedMessage
const { blocks } = reduceTimeline([agentMsg], makeContext())
// text block with same content as Agent.input.prompt must be suppressed
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(0)
})
it('keeps toolBlocksById reference identity when applying turn-duration to a tool-call', () => {
const toolCallMsg: TracedMessage = {
id: 'msg-tool',
+5 -4
View File
@@ -3,6 +3,7 @@ import type { TracedMessage } from '@/chat/tracer'
import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput'
import { parseMessageAsEvent } from '@/chat/reducerEvents'
import { ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools'
import { isSubagentToolName } from '@/chat/subagentTool'
export function reduceTimeline(
messages: TracedMessage[],
@@ -124,11 +125,11 @@ export function reduceTimeline(
}
if (msg.role === 'agent') {
// When the message contains a Task tool_use, Claude often writes the
// prompt as a text block before the tool_use block. We only want to
// When the message contains a Task/Agent tool_use, Claude often writes
// the prompt as a text block before the tool_use block. We only want to
// suppress that exact prompt text — not every text block in the message.
const taskToolCall = msg.content.find(
(c) => c.type === 'tool-call' && c.name === 'Task'
(c) => c.type === 'tool-call' && isSubagentToolName(c.name)
)
const taskPromptText: string | null = (() => {
if (!taskToolCall || taskToolCall.type !== 'tool-call') return null
@@ -259,7 +260,7 @@ export function reduceTimeline(
block.tool.startedAt = msg.createdAt
}
if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) {
if (isSubagentToolName(c.name) && !context.consumedGroupIds.has(msg.id)) {
const sidechain = context.groups.get(msg.id) ?? null
if (sidechain && sidechain.length > 0) {
context.consumedGroupIds.add(msg.id)
+14
View File
@@ -0,0 +1,14 @@
/**
* Returns true when the tool name identifies a subagent invocation.
*
* The Claude Code SDK has used two names for the same concept:
* - 'Task' — earlier SDK releases
* - 'Agent' — later SDK releases (OPUS 4.7+ environment)
*
* Both share the same input shape: { prompt: string, subagent_type: string }.
* The tracer, reducer, and UI surfaces must treat them identically.
* Keeping both ensures sessions recorded under either name continue to work.
*/
export function isSubagentToolName(name: string): boolean {
return name === 'Task' || name === 'Agent'
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Tests for traceMessages — verifies that both Task and Agent tool names
* are indexed and matched when grouping sidechain messages.
*/
import { describe, expect, it } from 'vitest'
import type { NormalizedMessage } from '@/chat/types'
import { traceMessages } from '@/chat/tracer'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeAgentMsg(overrides: Partial<NormalizedMessage> & { id: string }): NormalizedMessage {
const { id, ...rest } = overrides
return {
id,
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
isSidechain: false,
content: [],
...rest,
} as NormalizedMessage
}
function makeToolCallMsg(
id: string,
toolName: 'Task' | 'Agent',
prompt: string,
): NormalizedMessage {
return makeAgentMsg({
id,
content: [
{
type: 'tool-call',
id: `tc-${id}`,
name: toolName,
input: { prompt, subagent_type: 'general-purpose' },
description: null,
uuid: `uuid-${id}`,
parentUUID: null,
},
],
})
}
function makeSidechainRootMsg(id: string, prompt: string): NormalizedMessage {
return {
id,
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
isSidechain: true,
content: [
{
type: 'sidechain',
uuid: `uuid-sc-${id}`,
prompt,
},
],
} as NormalizedMessage
}
// ---------------------------------------------------------------------------
// Task — existing behaviour preserved
// ---------------------------------------------------------------------------
describe('traceMessages — Task tool name (preserved)', () => {
it('matches sidechain root to a Task tool_use message', () => {
const prompt = 'list .ts files'
const taskMsg = makeToolCallMsg('msg-task', 'Task', prompt)
const sidechainRoot = makeSidechainRootMsg('sc-root', prompt)
const result = traceMessages([taskMsg, sidechainRoot])
const sc = result.find(m => m.id === 'sc-root')
expect(sc).toBeDefined()
expect(sc!.sidechainId).toBe('msg-task')
})
it('does not assign sidechainId when prompt does not match', () => {
const taskMsg = makeToolCallMsg('msg-task', 'Task', 'original prompt')
const sidechainRoot = makeSidechainRootMsg('sc-root', 'different prompt')
const result = traceMessages([taskMsg, sidechainRoot])
const sc = result.find(m => m.id === 'sc-root')
expect(sc).toBeDefined()
expect(sc!.sidechainId).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// Agent — new SDK tool name (regression fix)
// ---------------------------------------------------------------------------
describe('traceMessages — Agent tool name (regression fix)', () => {
it('indexes Agent prompt and matches sidechain root to the Agent message', () => {
const prompt = 'explore the repo structure'
const agentMsg = makeToolCallMsg('msg-agent', 'Agent', prompt)
const sidechainRoot = makeSidechainRootMsg('sc-root', prompt)
const result = traceMessages([agentMsg, sidechainRoot])
const sc = result.find(m => m.id === 'sc-root')
expect(sc).toBeDefined()
// Before fix: sidechainId would be undefined because 'Agent' was not indexed
expect(sc!.sidechainId).toBe('msg-agent')
})
it('does not assign sidechainId when Agent prompt does not match', () => {
const agentMsg = makeToolCallMsg('msg-agent', 'Agent', 'original prompt')
const sidechainRoot = makeSidechainRootMsg('sc-root', 'different prompt')
const result = traceMessages([agentMsg, sidechainRoot])
const sc = result.find(m => m.id === 'sc-root')
expect(sc!.sidechainId).toBeUndefined()
})
it('handles both Task and Agent in the same message list', () => {
const taskPrompt = 'task prompt'
const agentPrompt = 'agent prompt'
const taskMsg = makeToolCallMsg('msg-task', 'Task', taskPrompt)
const agentMsg = makeToolCallMsg('msg-agent', 'Agent', agentPrompt)
const scForTask = makeSidechainRootMsg('sc-task', taskPrompt)
const scForAgent = makeSidechainRootMsg('sc-agent', agentPrompt)
const result = traceMessages([taskMsg, agentMsg, scForTask, scForAgent])
const scTaskResult = result.find(m => m.id === 'sc-task')
const scAgentResult = result.find(m => m.id === 'sc-agent')
expect(scTaskResult!.sidechainId).toBe('msg-task')
expect(scAgentResult!.sidechainId).toBe('msg-agent')
})
})
+3 -2
View File
@@ -1,5 +1,6 @@
import type { NormalizedMessage } from '@/chat/types'
import { isObject } from '@hapi/protocol'
import { isSubagentToolName } from '@/chat/subagentTool'
export type TracedMessage = NormalizedMessage & {
sidechainId?: string
@@ -58,11 +59,11 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] {
const results: TracedMessage[] = []
// Index Task prompts (including those inside sidechains).
// Index Task/Agent prompts (including those inside sidechains).
for (const message of messages) {
if (message.role !== 'agent') continue
for (const content of message.content) {
if (content.type !== 'tool-call' || content.name !== 'Task') continue
if (content.type !== 'tool-call' || !isSubagentToolName(content.name)) continue
const input = content.input
if (!isObject(input) || typeof input.prompt !== 'string') continue
state.promptToTaskId.set(input.prompt, message.id)
@@ -2,6 +2,7 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ChatBlock } from '@/chat/types'
import type { ToolCallBlock } from '@/chat/types'
import { isObject, safeStringify } from '@hapi/protocol'
import { isSubagentToolName } from '@/chat/subagentTool'
import { getEventPresentation } from '@/chat/presentation'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
@@ -109,7 +110,7 @@ function HappyNestedBlockList(props: {
}
if (block.kind === 'tool-call') {
const isTask = block.tool.name === 'Task'
const isTask = isSubagentToolName(block.tool.name)
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
@@ -199,7 +200,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) {
}
const block = artifact
const isTask = block.tool.name === 'Task'
const isTask = isSubagentToolName(block.tool.name)
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
+4 -3
View File
@@ -22,6 +22,7 @@ import { getInputString, getInputStringAny, truncate } from '@/lib/toolInputUtil
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { TraceSection } from '@/components/ToolCard/trace'
import { isSubagentToolName } from '@/chat/subagentTool'
const ELAPSED_INTERVAL_MS = 1000
@@ -47,7 +48,7 @@ function ElapsedView(props: { from: number; active: boolean }) {
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (block.tool.name !== 'Task') return null
if (!isSubagentToolName(block.tool.name)) return null
const children = block.children
.filter((child): child is ToolCallBlock => child.kind === 'tool-call')
@@ -126,7 +127,7 @@ function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'i
const toolName = block.tool.name
const input = block.tool.input
if (toolName === 'Task' && isObject(input) && typeof input.prompt === 'string') {
if (isSubagentToolName(toolName) && isObject(input) && typeof input.prompt === 'string') {
return <MarkdownRenderer content={input.prompt} />
}
@@ -287,7 +288,7 @@ function ToolCardInner(props: ToolCardProps) {
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const showInline = !presentation.minimal && toolName !== 'Task'
const showInline = !presentation.minimal && !isSubagentToolName(toolName)
const CompactToolView = showInline ? getToolViewComponent(toolName) : null
const FullToolView = getToolFullViewComponent(toolName)
const ResultToolView = getToolResultViewComponent(toolName)
+5 -2
View File
@@ -294,13 +294,16 @@ export const knownTools: Record<string, {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const description = getInputStringAny(opts.input, ['description'])
return description ?? 'Agent'
return description ?? 'Launch Agent'
},
subtitle: (opts) => {
// Subagent invocation: show prompt preview (same as Task)
const prompt = getInputStringAny(opts.input, ['prompt'])
if (prompt) return truncate(prompt, 120)
const model = getInputStringAny(opts.input, ['subagent_type'])
return model ?? null
},
minimal: true
minimal: (opts) => opts.childrenCount === 0
},
CodexReasoning: {
icon: () => <BulbIcon className={DEFAULT_ICON_CLASS} />,
@@ -95,6 +95,31 @@ function makeTaskBlock(
}
}
function makeAgentBlock(
children: ToolCallBlock[],
state: ToolCallBlock['tool']['state'] = 'completed',
result: unknown = null,
): ToolCallBlock {
return {
kind: 'tool-call',
id: 'agent-1',
localId: null,
createdAt: 1000,
tool: {
id: 'agent-1',
name: 'Agent',
state,
input: { prompt: 'do stuff', subagent_type: 'general-purpose' },
createdAt: 1000,
startedAt: 1000,
completedAt: 2000,
description: null,
result,
},
children,
}
}
// ---------------------------------------------------------------------------
// getTaskTraceChildren
// ---------------------------------------------------------------------------
@@ -139,6 +164,22 @@ describe('getTaskTraceChildren', () => {
}
expect(getTaskTraceChildren(block)).toBeNull()
})
// Agent tool name (new SDK name for subagent invocations)
it('returns children for Agent blocks (same as Task)', () => {
const block = makeAgentBlock([
makeChild('c1', 'Glob'),
makeChild('c2', 'Grep'),
])
const result = getTaskTraceChildren(block)
expect(result).not.toBeNull()
expect(result!.length).toBe(2)
})
it('returns null for Agent block with no tool-call children', () => {
const block = makeAgentBlock([])
expect(getTaskTraceChildren(block)).toBeNull()
})
})
// ---------------------------------------------------------------------------
@@ -267,4 +308,29 @@ describe('TraceSection', () => {
// Result section label must also be present
expect(screen.getByText('Result')).toBeInTheDocument()
})
// Agent tool name — same Trace UX as Task
it('renders Trace header for Agent blocks', () => {
const block = makeAgentBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')])
const { container } = render(<TraceSection block={block} metadata={null} />)
// TraceSection must mount (non-null) for Agent blocks
expect(container.firstChild).not.toBeNull()
// header button with aria-expanded attribute must exist
const btn = container.querySelector('button[aria-expanded]')
expect(btn).not.toBeNull()
})
it('renders nothing for Agent block with no children', () => {
const block = makeAgentBlock([])
const { container } = render(<TraceSection block={block} metadata={null} />)
expect(container.firstChild).toBeNull()
})
it('expands Agent trace by default when running', () => {
const block = makeAgentBlock([makeChild('c1', 'Read')], 'running')
const { container } = render(<TraceSection block={block} metadata={null} />)
const headerBtn = container.querySelector('button[aria-expanded="true"]')
expect(headerBtn).not.toBeNull()
expect(container.querySelector('.border-l')).not.toBeNull()
})
})
+4 -3
View File
@@ -1,5 +1,5 @@
/**
* TraceSection — shows child tool calls inside a Task tool dialog.
* TraceSection — shows child tool calls inside a Task/Agent tool dialog.
* Placed between Input and Result sections.
*/
import { useState } from 'react'
@@ -11,6 +11,7 @@ import { getToolResultViewComponent } from '@/components/ToolCard/views/_results
import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers'
import { CodeBlock } from '@/components/CodeBlock'
import { useTranslation } from '@/lib/use-translation'
import { isSubagentToolName } from '@/chat/subagentTool'
// ---------------------------------------------------------------------------
// Result type narrowing (trace.tsx-internal; do NOT move to shared protocol)
@@ -46,10 +47,10 @@ type _TaskToolResultSummary = TaskToolResultSummary
// ---------------------------------------------------------------------------
/**
* Returns tool-call children of the given Task block, or null if none exist.
* Returns tool-call children of the given Task/Agent block, or null if none exist.
*/
export function getTaskTraceChildren(block: ToolCallBlock): ToolCallBlock[] | null {
if (block.tool.name !== 'Task') return null
if (!isSubagentToolName(block.tool.name)) return null
const children = block.children.filter(
(c): c is ToolCallBlock => c.kind === 'tool-call',
)
@@ -11,7 +11,6 @@ import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/Mu
import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView'
import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView'
import { WriteView } from '@/components/ToolCard/views/WriteView'
import { isObject } from '@hapi/protocol'
import { getInputStringAny } from '@/lib/toolInputUtils'
export type ToolViewProps = {
@@ -31,25 +30,6 @@ const SkillFullView: ToolViewComponent = ({ block }: ToolViewProps) => {
)
}
const AgentFullView: ToolViewComponent = ({ block }: ToolViewProps) => {
const input = block.tool.input
const description = getInputStringAny(input, ['description'])
const subagentType = getInputStringAny(input, ['subagent_type'])
const runInBackground = isObject(input) && input.run_in_background === true
return (
<div className="flex flex-col gap-1 text-sm">
{description && (
<div className="text-[var(--app-fg)]">{description}</div>
)}
<div className="flex gap-3 text-[var(--app-hint)]">
{subagentType && <span>Type: {subagentType}</span>}
{runInBackground && <span>Background</span>}
</div>
</div>
)
}
export const toolViewRegistry: Record<string, ToolViewComponent> = {
Edit: EditView,
MultiEdit: MultiEditView,
@@ -71,7 +51,6 @@ export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
CodexDiff: CodexDiffFullView,
CodexPatch: CodexPatchView,
Skill: SkillFullView,
Agent: AgentFullView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
@@ -138,6 +138,12 @@ describe('getToolResultViewComponent registry', () => {
it('uses a dedicated result view for CodexBash', () => {
expect(getToolResultViewComponent('CodexBash')).not.toBe(getToolResultViewComponent('SomeUnknownTool'))
})
it('Agent falls back to GenericResultView (no dedicated view — view layer must not filter content)', () => {
const agentView = getToolResultViewComponent('Agent')
const genericView = getToolResultViewComponent('SomeUnknownTool')
expect(agentView).toBe(genericView)
})
})
describe('dialog result formatting', () => {
@@ -751,46 +751,6 @@ const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => {
return <ChecklistList items={todos} />
}
const AgentResultView: ToolViewComponent = (props: ToolViewProps) => {
const { state, result } = props.block.tool
if (result === undefined || result === null) {
return <ResultStatusPill text={placeholderForState(state)} />
}
// For errors, show the error text
if (state === 'error') {
const text = extractTextFromResult(result)
return (
<div className="text-sm text-red-600">
{text?.trim() ? text : 'Agent failed'}
</div>
)
}
const text = extractTextFromResult(result)
if (!text) {
return <ResultStatusPill text={state === 'completed' ? 'Done' : placeholderForState(state)} />
}
// Detect internal launch metadata. Check structurally first (result object
// may carry agentId/output_file keys), then fall back to a strict text
// pattern that is unlikely to appear in normal agent prose.
const isInternalMeta = isObject(result) && ('agentId' in result || 'output_file' in result)
|| (text.startsWith('Async agent launched successfully.') && text.includes('agentId:'))
if (isInternalMeta) {
return <ResultStatusPill text="Agent launched" />
}
return (
<>
{renderResultBody(renderMarkdown(text, props.surface), props.surface)}
<RawJsonDevOnly value={result} surface={props.surface} />
</>
)
}
const SkillResultView: ToolViewComponent = (props: ToolViewProps) => {
const { state, result, input } = props.block.tool
@@ -889,7 +849,6 @@ export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
CodexPatch: CodexPatchResultView,
CodexDiff: CodexDiffResultView,
Skill: SkillResultView,
Agent: AgentResultView,
AskUserQuestion: AskUserQuestionResultView,
ExitPlanMode: MarkdownResultView,
ask_user_question: AskUserQuestionResultView,