Skip to content

Commit f725f00

Browse files
author
echoVic
committed
feat(子任务): 增强子任务执行状态展示和交互
扩展子任务进度类型定义,增加会话ID和输出等字段 实现子任务工具调用和状态更新的完整事件处理链 重构子任务展示组件,支持折叠展开和实时内容更新 添加子任务取消时的状态处理逻辑
1 parent c39a083 commit f725f00

9 files changed

Lines changed: 414 additions & 108 deletions

File tree

packages/cli/src/agent/subagents/SubagentExecutor.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,15 @@ export class SubagentExecutor {
5252
subagentInfo,
5353
},
5454
{
55-
onToolStart: context.onToolStart
56-
? (toolCall) => {
57-
const name =
58-
'function' in toolCall ? toolCall.function.name : 'unknown';
59-
context.onToolStart!(name);
55+
onToolStart: context.onToolStart,
56+
onToolResult: context.onToolResult
57+
? async (toolCall, result) => {
58+
context.onToolResult?.(toolCall, result);
6059
}
6160
: undefined,
61+
onContentDelta: context.onContentDelta,
62+
onThinkingDelta: context.onThinkingDelta,
63+
onStreamEnd: context.onStreamEnd,
6264
}
6365
);
6466

packages/cli/src/agent/subagents/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* Subagent 系统类型定义
33
*/
44

5+
import type { ChatCompletionMessageToolCall } from 'openai/resources/chat';
56
import { PermissionMode } from '../../config/types.js';
7+
import type { ToolResult } from '../../tools/types/index.js';
68

79
/**
810
* Claude Code permissionMode 类型
@@ -125,7 +127,18 @@ export interface SubagentContext {
125127
subagentSessionId?: string;
126128

127129
/** 工具执行开始回调(用于 UI 进度显示) */
128-
onToolStart?: (toolName: string) => void;
130+
onToolStart?: (
131+
toolCall: ChatCompletionMessageToolCall,
132+
toolKind?: 'readonly' | 'write' | 'execute'
133+
) => void;
134+
onToolResult?: (
135+
toolCall: ChatCompletionMessageToolCall,
136+
result: ToolResult
137+
) => void;
138+
139+
onContentDelta?: (delta: string) => void;
140+
onThinkingDelta?: (delta: string) => void;
141+
onStreamEnd?: () => void;
129142
}
130143

131144
/**

packages/cli/src/server/routes/session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ export const SessionRoutes = () => {
254254
activeRuns.delete(session.currentRunId);
255255
}
256256
}
257+
try {
258+
await SessionService.deleteSession(sessionId);
259+
} catch (error) {
260+
logger.warn('[SessionRoutes] Failed to delete session file:', error);
261+
}
257262
sessions.delete(sessionId);
258263

259264
return c.json({ success: true });

packages/cli/src/services/SessionService.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* 负责加载和恢复历史会话
44
*/
55

6-
import { readdir, readFile } from 'node:fs/promises';
6+
import { readdir, readFile, rm } from 'node:fs/promises';
77
import * as path from 'node:path';
88
import {
99
getBladeStorageRoot,
@@ -173,6 +173,18 @@ export class SessionService {
173173
}
174174
}
175175

176+
static async deleteSession(sessionId: string): Promise<number> {
177+
const sessions = await this.listSessions();
178+
const matches = sessions.filter((s) => s.sessionId === sessionId);
179+
if (matches.length === 0) return 0;
180+
await Promise.all(
181+
matches.map((s) =>
182+
rm(s.filePath, { force: true })
183+
)
184+
);
185+
return matches.length;
186+
}
187+
176188
/**
177189
* 从 JSONL 文件加载并转换消息
178190
*/

packages/cli/src/tools/builtin/task/task.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
} from '../../../agent/subagents/types.js';
2121
import { PermissionMode } from '../../../config/types.js';
2222
import { HookManager } from '../../../hooks/HookManager.js';
23+
import { Bus } from '../../../server/bus.js';
2324
import { vanillaStore } from '../../../store/vanilla.js';
2425
import { createTool } from '../../core/createTool.js';
2526
import type { ExecutionContext, ToolResult } from '../../types/index.js';
@@ -209,6 +210,7 @@ export const taskTool = createTool({
209210
subagent_session_id,
210211
} = params;
211212
const { updateOutput } = context;
213+
const parentSessionId = context.sessionId;
212214
const subagentSessionId =
213215
typeof subagent_session_id === 'string' && subagent_session_id.length > 0
214216
? subagent_session_id
@@ -267,8 +269,61 @@ export const taskTool = createTool({
267269
parentSessionId: context.sessionId,
268270
permissionMode: context.permissionMode, // 继承父 Agent 的权限模式
269271
subagentSessionId,
270-
onToolStart: (toolName) => {
272+
onToolStart: (toolCall, toolKind) => {
273+
const toolName =
274+
toolCall.type === 'function' ? toolCall.function.name : 'Unknown';
271275
vanillaStore.getState().app.actions.updateSubagentTool(toolName);
276+
if (parentSessionId) {
277+
Bus.publish(parentSessionId, 'subagent.update', {
278+
subagentSessionId,
279+
toolName,
280+
});
281+
if (toolCall.type === 'function') {
282+
Bus.publish(parentSessionId, 'subagent.tool.start', {
283+
subagentSessionId,
284+
toolCallId: toolCall.id,
285+
toolName,
286+
arguments: toolCall.function.arguments,
287+
toolKind,
288+
});
289+
}
290+
}
291+
},
292+
onToolResult: (toolCall, result) => {
293+
if (!parentSessionId) return;
294+
if (toolCall.type !== 'function') return;
295+
Bus.publish(parentSessionId, 'subagent.tool.result', {
296+
subagentSessionId,
297+
toolCallId: toolCall.id,
298+
toolName: toolCall.function.name,
299+
success: !result.error,
300+
summary: result.metadata?.summary,
301+
output: result.displayContent,
302+
metadata: result.metadata,
303+
});
304+
},
305+
onContentDelta: (delta) => {
306+
if (parentSessionId) {
307+
Bus.publish(parentSessionId, 'subagent.delta', {
308+
subagentSessionId,
309+
delta,
310+
});
311+
}
312+
},
313+
onThinkingDelta: (delta) => {
314+
if (parentSessionId) {
315+
Bus.publish(parentSessionId, 'subagent.thinking.delta', {
316+
subagentSessionId,
317+
delta,
318+
});
319+
}
320+
},
321+
onStreamEnd: () => {
322+
if (parentSessionId) {
323+
Bus.publish(parentSessionId, 'subagent.stream.end', {
324+
subagentSessionId,
325+
});
326+
}
272327
},
273328
};
274329

packages/cli/web/src/components/chat/ChatMessage.tsx

Lines changed: 61 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { AgentResponseContent, Message, ToolCallInfo } from '@/store/sessio
55
import { useSessionStore } from '@/store/session'
66
import { aggregateMessages } from '@/store/session/utils/aggregateMessages'
77
import { ChevronDown, ChevronRight, FileText, Loader2 } from 'lucide-react'
8-
import { useMemo, useState } from 'react'
8+
import { useEffect, useMemo, useState } from 'react'
99
import { MarkdownRenderer } from './MarkdownRenderer'
1010

1111
export type { Message }
@@ -225,107 +225,83 @@ function ChangedFilesSection({ toolCalls }: { toolCalls: ToolCallInfo[] }) {
225225
}
226226

227227
function SubagentSection({ subagent }: { subagent: AgentResponseContent['subagent'] }) {
228+
const [manualToggle, setManualToggle] = useState<boolean | null>(null)
229+
const [loading, setLoading] = useState(false)
230+
const [loadedToolCalls, setLoadedToolCalls] = useState<ToolCallInfo[] | null>(null)
231+
228232
if (!subagent) return null
229233

230-
return (
231-
<div className="bg-[#F9FAFB] dark:bg-[#18181b] border border-[#E5E7EB] dark:border-[#27272a] rounded-lg px-3 py-2">
232-
<div className="flex gap-2 items-center">
233-
{subagent.status === 'running' && <Loader2 className="h-3 w-3 animate-spin text-[#6B7280]" />}
234-
<span className="text-[12px] text-[#6B7280] dark:text-[#71717a] font-mono">
235-
{subagent.type}: {subagent.description}
236-
</span>
237-
<StatusPill status={subagent.status === 'completed' ? 'success' : subagent.status === 'failed' ? 'error' : 'running'} />
238-
</div>
239-
</div>
240-
)
241-
}
242-
243-
function SubtaskRefSection({ subtaskRef }: { subtaskRef: Record<string, unknown> }) {
244-
const { currentSessionId } = useSessionStore()
245-
const [expanded, setExpanded] = useState(false)
246-
const [loading, setLoading] = useState(false)
247-
const [subagentToolCalls, setSubagentToolCalls] = useState<ToolCallInfo[] | null>(null)
248-
const status = typeof subtaskRef.status === 'string' ? subtaskRef.status : undefined
249-
const summary = typeof subtaskRef.summary === 'string' ? subtaskRef.summary : ''
250-
const agentType = typeof subtaskRef.agentType === 'string' ? subtaskRef.agentType : 'subagent'
251-
const sessionId = typeof subtaskRef.childSessionId === 'string' ? subtaskRef.childSessionId : undefined
252-
const pillStatus =
253-
status === 'completed' ? 'success' : status === 'failed' ? 'error' : status === 'running' ? 'running' : 'info'
254-
const isActive = sessionId && sessionId === currentSessionId
255-
const canExpand = !!sessionId
256-
257-
const handleToggleExpand = async () => {
258-
if (!sessionId) return
259-
const next = !expanded
260-
setExpanded(next)
261-
if (next && subagentToolCalls === null) {
262-
setLoading(true)
263-
try {
264-
const rawMessages = await sessionService.getMessages(sessionId)
265-
const aggregated = aggregateMessages(rawMessages)
266-
const toolCalls: ToolCallInfo[] = []
267-
for (const message of aggregated) {
268-
if (message.agentContent?.toolCalls?.length) {
269-
toolCalls.push(...message.agentContent.toolCalls)
234+
const isRunning = subagent.status === 'running'
235+
const expanded = manualToggle !== null ? manualToggle : isRunning
236+
const toolCalls = subagent.toolCalls || loadedToolCalls || []
237+
const hasContent = subagent.output || subagent.thinking || toolCalls.length > 0 || subagent.sessionId
238+
239+
useEffect(() => {
240+
if (!expanded || isRunning || !subagent.sessionId || loadedToolCalls !== null || (subagent.toolCalls && subagent.toolCalls.length > 0)) return
241+
let mounted = true
242+
setLoading(true)
243+
sessionService.getMessages(subagent.sessionId).then((rawMessages) => {
244+
if (!mounted) return
245+
const aggregated = aggregateMessages(rawMessages)
246+
const toolCallsMap = new Map<string, ToolCallInfo>()
247+
for (const message of aggregated) {
248+
if (message.agentContent?.toolCalls?.length) {
249+
for (const tc of message.agentContent.toolCalls) {
250+
if (!toolCallsMap.has(tc.toolCallId)) {
251+
toolCallsMap.set(tc.toolCallId, tc)
252+
}
270253
}
271254
}
272-
setSubagentToolCalls(toolCalls)
273-
} finally {
274-
setLoading(false)
275255
}
276-
}
277-
}
256+
setLoadedToolCalls(Array.from(toolCallsMap.values()))
257+
}).finally(() => {
258+
if (mounted) setLoading(false)
259+
})
260+
return () => { mounted = false }
261+
}, [expanded, isRunning, subagent.sessionId, subagent.toolCalls, loadedToolCalls])
278262

279263
return (
280-
<div
281-
className={cn(
282-
'bg-[#F9FAFB] dark:bg-[#18181b] border border-[#E5E7EB] dark:border-[#27272a] rounded-lg px-3 py-2 space-y-1',
283-
isActive ? 'ring-1 ring-[#22C55E]/60' : ''
284-
)}
285-
>
286-
<div className="flex gap-2 justify-between items-center">
264+
<div className="bg-[#F9FAFB] dark:bg-[#18181b] border border-[#E5E7EB] dark:border-[#27272a] rounded-lg px-3 py-2">
265+
<button
266+
type="button"
267+
onClick={() => setManualToggle(!expanded)}
268+
className="flex gap-2 justify-between items-center w-full transition-opacity cursor-pointer hover:opacity-80"
269+
>
287270
<div className="flex gap-2 items-center">
271+
{isRunning && <Loader2 className="h-3 w-3 animate-spin text-[#6B7280]" />}
288272
<span className="text-[12px] text-[#6B7280] dark:text-[#71717a] font-mono">
289-
Subtask @{agentType}
273+
{subagent.type}: {subagent.description}
290274
</span>
291-
<StatusPill status={pillStatus} />
275+
<StatusPill status={subagent.status === 'completed' ? 'success' : subagent.status === 'failed' ? 'error' : 'running'} />
292276
</div>
293-
<button
294-
type="button"
295-
onClick={(event) => {
296-
event.stopPropagation()
297-
void handleToggleExpand()
298-
}}
299-
disabled={!canExpand}
300-
className={cn(
301-
'flex items-center gap-1 text-[11px] font-mono px-2 py-1 rounded',
302-
canExpand
303-
? 'text-[#6B7280] hover:text-[#111827] hover:bg-[#E5E7EB] dark:text-[#71717a] dark:hover:text-[#E5E5E5] dark:hover:bg-[#27272a]'
304-
: 'text-[#9CA3AF] dark:text-[#52525b] cursor-not-allowed'
305-
)}
306-
>
307-
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
308-
Logs
309-
</button>
310-
</div>
311-
{summary && (
312-
<div className="text-[12px] text-[#374151] dark:text-[#d4d4d8] whitespace-pre-wrap">{summary}</div>
313-
)}
277+
{hasContent && (
278+
<ChevronDown className={cn('h-3.5 w-3.5 text-[#6B7280] transition-transform', expanded && 'rotate-180')} />
279+
)}
280+
</button>
314281
{expanded && (
315-
<div className="pt-2">
282+
<div className="mt-2 space-y-2">
283+
{(subagent.output || subagent.thinking) && (
284+
<>
285+
{subagent.output && (
286+
<pre className="text-[11px] text-[#374151] dark:text-[#d4d4d8] bg-[#F3F4F6] dark:bg-[#111113] border border-[#E5E7EB] dark:border-[#27272a] rounded-md p-2 overflow-x-auto whitespace-pre-wrap font-mono max-h-[160px] overflow-y-auto">
287+
{subagent.output}
288+
</pre>
289+
)}
290+
{subagent.thinking && (
291+
<pre className="text-[11px] text-[#6B7280] dark:text-[#a1a1aa] bg-[#F3F4F6] dark:bg-[#111113] border border-[#E5E7EB] dark:border-[#27272a] rounded-md p-2 overflow-x-auto whitespace-pre-wrap font-mono max-h-[120px] overflow-y-auto">
292+
{subagent.thinking}
293+
</pre>
294+
)}
295+
</>
296+
)}
316297
{loading && (
317298
<div className="flex items-center gap-2 text-[11px] text-[#6B7280] dark:text-[#71717a] font-mono">
318299
<Loader2 className="w-3 h-3 animate-spin" />
319300
Loading subagent logs...
320301
</div>
321302
)}
322-
{!loading && subagentToolCalls && subagentToolCalls.length > 0 && (
323-
<ToolCallsList toolCalls={subagentToolCalls} />
324-
)}
325-
{!loading && subagentToolCalls && subagentToolCalls.length === 0 && (
326-
<div className="text-[11px] text-[#6B7280] dark:text-[#71717a] font-mono">
327-
No tool calls recorded.
328-
</div>
303+
{!loading && toolCalls.length > 0 && (
304+
<ToolCallsList toolCalls={toolCalls} />
329305
)}
330306
</div>
331307
)}
@@ -487,11 +463,7 @@ function AgentMessageContent({ message }: { message: Message }) {
487463
}
488464

489465
const { textBefore, toolCalls, textAfter, thinkingContent, todos, subagent, confirmation, question } = agentContent
490-
const subtaskRef =
491-
message.metadata && typeof message.metadata === 'object' && 'subtaskRef' in message.metadata
492-
? (message.metadata.subtaskRef as Record<string, unknown>)
493-
: null
494-
const hasContent = textBefore || toolCalls.length > 0 || textAfter || thinkingContent || todos.length > 0 || subagent || confirmation || question || subtaskRef
466+
const hasContent = textBefore || toolCalls.length > 0 || textAfter || thinkingContent || todos.length > 0 || subagent || confirmation || question
495467

496468
if (!hasContent && isCurrentMessage && isStreaming) {
497469
return (
@@ -511,7 +483,6 @@ function AgentMessageContent({ message }: { message: Message }) {
511483
{textBefore && <MarkdownRenderer content={textBefore} />}
512484
{todos.length > 0 && <TodoSection todos={todos} />}
513485
{subagent && <SubagentSection subagent={subagent} />}
514-
{subtaskRef && <SubtaskRefSection subtaskRef={subtaskRef} />}
515486
<ToolCallsList toolCalls={toolCalls} />
516487
{confirmation && <ConfirmationSection confirmation={confirmation} messageId={message.id} />}
517488
{question && <QuestionSection question={question} />}

0 commit comments

Comments
 (0)