From 6b09aee32bb9a2c88639b3556957aef76296336d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 10 Sep 2025 13:41:28 +0800 Subject: [PATCH] feat: subagent feature wip --- packages/cli/src/ui/App.tsx | 4 +- .../components/messages/ToolMessage.test.tsx | 2 +- .../ui/components/messages/ToolMessage.tsx | 4 +- .../AgentCreationWizard.tsx} | 16 +-- .../subagents/{ => create}/ColorSelector.tsx | 8 +- .../{ => create}/CreationSummary.tsx | 8 +- .../{ => create}/DescriptionInput.tsx | 16 +-- .../{ => create}/GenerationMethodSelector.tsx | 4 +- .../{ => create}/LocationSelector.tsx | 4 +- .../subagents/{ => create}/ToolSelector.tsx | 6 +- .../cli/src/ui/components/subagents/index.ts | 34 +---- .../AgentExecutionDisplay.tsx} | 88 +++++++----- .../{ => view}/ActionSelectionStep.tsx | 4 +- .../subagents/{ => view}/AgentDeleteStep.tsx | 6 +- .../subagents/{ => view}/AgentEditStep.tsx | 8 +- .../{ => view}/AgentSelectionStep.tsx | 6 +- .../subagents/{ => view}/AgentViewerStep.tsx | 4 +- .../{ => view}/AgentsManagerDialog.tsx | 10 +- .../subagents => hooks}/useLaunchEditor.ts | 2 +- .../core/src/core/coreToolScheduler.test.ts | 130 ++++++++++++++++++ packages/core/src/core/coreToolScheduler.ts | 32 +++-- packages/core/src/subagents/index.ts | 1 - packages/core/src/subagents/subagent.test.ts | 22 ++- packages/core/src/subagents/subagent.ts | 43 +++--- packages/core/src/subagents/types.ts | 18 +-- packages/core/src/telemetry/metrics.ts | 2 +- packages/core/src/telemetry/types.ts | 4 +- packages/core/src/tools/task.test.ts | 26 +--- packages/core/src/tools/task.ts | 54 ++++---- packages/core/src/tools/tools.ts | 2 +- 30 files changed, 329 insertions(+), 239 deletions(-) rename packages/cli/src/ui/components/subagents/{SubagentCreationWizard.tsx => create/AgentCreationWizard.tsx} (95%) rename packages/cli/src/ui/components/subagents/{ => create}/ColorSelector.tsx (90%) rename packages/cli/src/ui/components/subagents/{ => create}/CreationSummary.tsx (97%) rename packages/cli/src/ui/components/subagents/{ => create}/DescriptionInput.tsx (95%) rename packages/cli/src/ui/components/subagents/{ => create}/GenerationMethodSelector.tsx (90%) rename packages/cli/src/ui/components/subagents/{ => create}/LocationSelector.tsx (90%) rename packages/cli/src/ui/components/subagents/{ => create}/ToolSelector.tsx (97%) rename packages/cli/src/ui/components/subagents/{SubagentExecutionDisplay.tsx => runtime/AgentExecutionDisplay.tsx} (81%) rename packages/cli/src/ui/components/subagents/{ => view}/ActionSelectionStep.tsx (94%) rename packages/cli/src/ui/components/subagents/{ => view}/AgentDeleteStep.tsx (88%) rename packages/cli/src/ui/components/subagents/{ => view}/AgentEditStep.tsx (91%) rename packages/cli/src/ui/components/subagents/{ => view}/AgentSelectionStep.tsx (98%) rename packages/cli/src/ui/components/subagents/{ => view}/AgentViewerStep.tsx (92%) rename packages/cli/src/ui/components/subagents/{ => view}/AgentsManagerDialog.tsx (97%) rename packages/cli/src/ui/{components/subagents => hooks}/useLaunchEditor.ts (97%) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index b5abf4cd..127449b6 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -44,7 +44,7 @@ import { FolderTrustDialog } from './components/FolderTrustDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; import { - SubagentCreationWizard, + AgentCreationWizard, AgentsManagerDialog, } from './components/subagents/index.js'; import { Colors } from './colors.js'; @@ -1093,7 +1093,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ) : isSubagentCreateDialogOpen ? ( - diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index cd3ace49..e5cd517c 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -40,7 +40,7 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ }, })); vi.mock('../subagents/index.js', () => ({ - SubagentExecutionDisplay: function MockSubagentExecutionDisplay({ + AgentExecutionDisplay: function MockAgentExecutionDisplay({ data, }: { data: { subagentName: string; taskDescription: string }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index abe5225a..c81f3238 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -17,7 +17,7 @@ import { TodoResultDisplay, TaskResultDisplay, } from '@qwen-code/qwen-code-core'; -import { SubagentExecutionDisplay } from '../subagents/index.js'; +import { AgentExecutionDisplay } from '../subagents/index.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -106,7 +106,7 @@ const SubagentExecutionRenderer: React.FC<{ data: TaskResultDisplay; availableHeight?: number; childWidth: number; -}> = ({ data }) => ; +}> = ({ data }) => ; /** * Component to render string results (markdown or plain text) diff --git a/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx similarity index 95% rename from packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx rename to packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx index 126b3e4b..a9446da9 100644 --- a/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx +++ b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx @@ -6,20 +6,20 @@ import { useReducer, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { wizardReducer, initialWizardState } from './reducers.js'; +import { wizardReducer, initialWizardState } from '../reducers.js'; import { LocationSelector } from './LocationSelector.js'; import { GenerationMethodSelector } from './GenerationMethodSelector.js'; import { DescriptionInput } from './DescriptionInput.js'; import { ToolSelector } from './ToolSelector.js'; import { ColorSelector } from './ColorSelector.js'; import { CreationSummary } from './CreationSummary.js'; -import { WizardStepProps } from './types.js'; -import { WIZARD_STEPS } from './constants.js'; +import { WizardStepProps } from '../types.js'; +import { WIZARD_STEPS } from '../constants.js'; import { Config } from '@qwen-code/qwen-code-core'; -import { Colors } from '../../colors.js'; -import { theme } from '../../semantic-colors.js'; +import { Colors } from '../../../colors.js'; +import { theme } from '../../../semantic-colors.js'; -interface SubagentCreationWizardProps { +interface AgentCreationWizardProps { onClose: () => void; config: Config | null; } @@ -27,10 +27,10 @@ interface SubagentCreationWizardProps { /** * Main orchestrator component for the subagent creation wizard. */ -export function SubagentCreationWizard({ +export function AgentCreationWizard({ onClose, config, -}: SubagentCreationWizardProps) { +}: AgentCreationWizardProps) { const [state, dispatch] = useReducer(wizardReducer, initialWizardState); const handleNext = useCallback(() => { diff --git a/packages/cli/src/ui/components/subagents/ColorSelector.tsx b/packages/cli/src/ui/components/subagents/create/ColorSelector.tsx similarity index 90% rename from packages/cli/src/ui/components/subagents/ColorSelector.tsx rename to packages/cli/src/ui/components/subagents/create/ColorSelector.tsx index 50af090f..bac5b768 100644 --- a/packages/cli/src/ui/components/subagents/ColorSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/ColorSelector.tsx @@ -6,10 +6,10 @@ import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { ColorOption } from './types.js'; -import { Colors } from '../../colors.js'; -import { COLOR_OPTIONS } from './constants.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { ColorOption } from '../types.js'; +import { Colors } from '../../../colors.js'; +import { COLOR_OPTIONS } from '../constants.js'; const colorOptions: ColorOption[] = COLOR_OPTIONS; diff --git a/packages/cli/src/ui/components/subagents/CreationSummary.tsx b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx similarity index 97% rename from packages/cli/src/ui/components/subagents/CreationSummary.tsx rename to packages/cli/src/ui/components/subagents/create/CreationSummary.tsx index b2a6f0b5..81683892 100644 --- a/packages/cli/src/ui/components/subagents/CreationSummary.tsx +++ b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx @@ -6,11 +6,11 @@ import { useCallback, useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; -import { WizardStepProps } from './types.js'; +import { WizardStepProps } from '../types.js'; import { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core'; -import { theme } from '../../semantic-colors.js'; -import { shouldShowColor, getColorForDisplay } from './utils.js'; -import { useLaunchEditor } from './useLaunchEditor.js'; +import { theme } from '../../../semantic-colors.js'; +import { shouldShowColor, getColorForDisplay } from '../utils.js'; +import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js'; /** * Step 6: Final confirmation and actions. diff --git a/packages/cli/src/ui/components/subagents/DescriptionInput.tsx b/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx similarity index 95% rename from packages/cli/src/ui/components/subagents/DescriptionInput.tsx rename to packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx index 04d5b7db..b69200e6 100644 --- a/packages/cli/src/ui/components/subagents/DescriptionInput.tsx +++ b/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx @@ -6,17 +6,17 @@ import { useState, useCallback, useRef } from 'react'; import { Box, Text } from 'ink'; -import { WizardStepProps, WizardAction } from './types.js'; -import { sanitizeInput } from './utils.js'; +import { WizardStepProps, WizardAction } from '../types.js'; +import { sanitizeInput } from '../utils.js'; import { Config, subagentGenerator } from '@qwen-code/qwen-code-core'; -import { useTextBuffer } from '../shared/text-buffer.js'; -import { useKeypress, Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; -import { theme } from '../../semantic-colors.js'; -import { cpSlice, cpLen } from '../../utils/textUtils.js'; +import { useTextBuffer } from '../../shared/text-buffer.js'; +import { useKeypress, Key } from '../../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../../keyMatchers.js'; +import { theme } from '../../../semantic-colors.js'; +import { cpSlice, cpLen } from '../../../utils/textUtils.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; -import { Colors } from '../../colors.js'; +import { Colors } from '../../../colors.js'; /** * Step 3: Description input with LLM generation. diff --git a/packages/cli/src/ui/components/subagents/GenerationMethodSelector.tsx b/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx similarity index 90% rename from packages/cli/src/ui/components/subagents/GenerationMethodSelector.tsx rename to packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx index 2cb0cf64..3b2748a2 100644 --- a/packages/cli/src/ui/components/subagents/GenerationMethodSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx @@ -5,8 +5,8 @@ */ import { Box } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { WizardStepProps } from './types.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { WizardStepProps } from '../types.js'; interface GenerationOption { label: string; diff --git a/packages/cli/src/ui/components/subagents/LocationSelector.tsx b/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx similarity index 90% rename from packages/cli/src/ui/components/subagents/LocationSelector.tsx rename to packages/cli/src/ui/components/subagents/create/LocationSelector.tsx index a8da5d74..297a3102 100644 --- a/packages/cli/src/ui/components/subagents/LocationSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx @@ -5,8 +5,8 @@ */ import { Box } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { WizardStepProps } from './types.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { WizardStepProps } from '../types.js'; interface LocationOption { label: string; diff --git a/packages/cli/src/ui/components/subagents/ToolSelector.tsx b/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx similarity index 97% rename from packages/cli/src/ui/components/subagents/ToolSelector.tsx rename to packages/cli/src/ui/components/subagents/create/ToolSelector.tsx index 17303bbf..00ca1e83 100644 --- a/packages/cli/src/ui/components/subagents/ToolSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx @@ -6,10 +6,10 @@ import { useState, useMemo, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { ToolCategory } from './types.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { ToolCategory } from '../types.js'; import { Kind, Config } from '@qwen-code/qwen-code-core'; -import { Colors } from '../../colors.js'; +import { Colors } from '../../../colors.js'; interface ToolOption { label: string; diff --git a/packages/cli/src/ui/components/subagents/index.ts b/packages/cli/src/ui/components/subagents/index.ts index 8e794c65..feb51675 100644 --- a/packages/cli/src/ui/components/subagents/index.ts +++ b/packages/cli/src/ui/components/subagents/index.ts @@ -4,33 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Creation Wizard Components -export { SubagentCreationWizard } from './SubagentCreationWizard.js'; -export { LocationSelector } from './LocationSelector.js'; -export { GenerationMethodSelector } from './GenerationMethodSelector.js'; -export { DescriptionInput } from './DescriptionInput.js'; -export { ToolSelector } from './ToolSelector.js'; -export { ColorSelector } from './ColorSelector.js'; -export { CreationSummary } from './CreationSummary.js'; +// Creation Wizard +export { AgentCreationWizard } from './create/AgentCreationWizard.js'; -// Management Dialog Components -export { AgentsManagerDialog } from './AgentsManagerDialog.js'; -export { AgentSelectionStep } from './AgentSelectionStep.js'; -export { ActionSelectionStep } from './ActionSelectionStep.js'; -export { AgentViewerStep } from './AgentViewerStep.js'; -export { AgentDeleteStep } from './AgentDeleteStep.js'; +// Management Dialog +export { AgentsManagerDialog } from './view/AgentsManagerDialog.js'; -// Execution Display Components -export { SubagentExecutionDisplay } from './SubagentExecutionDisplay.js'; - -// Creation Wizard Types and State -export type { - CreationWizardState, - WizardAction, - WizardStepProps, - WizardResult, - ToolCategory, - ColorOption, -} from './types.js'; - -export { wizardReducer, initialWizardState } from './reducers.js'; +// Execution Display +export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js'; diff --git a/packages/cli/src/ui/components/subagents/SubagentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx similarity index 81% rename from packages/cli/src/ui/components/subagents/SubagentExecutionDisplay.tsx rename to packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index 1451ef66..6f0e08b4 100644 --- a/packages/cli/src/ui/components/subagents/SubagentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -6,19 +6,19 @@ import React, { useMemo } from 'react'; import { Box, Text } from 'ink'; -import { Colors } from '../../colors.js'; +import { Colors } from '../../../colors.js'; import { TaskResultDisplay, SubagentStatsSummary, } from '@qwen-code/qwen-code-core'; -import { theme } from '../../semantic-colors.js'; -import { useKeypress } from '../../hooks/useKeypress.js'; -import { COLOR_OPTIONS } from './constants.js'; -import { fmtDuration } from './utils.js'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { COLOR_OPTIONS } from '../constants.js'; +import { fmtDuration } from '../utils.js'; -export type DisplayMode = 'compact' | 'default' | 'verbose'; +export type DisplayMode = 'default' | 'verbose'; -export interface SubagentExecutionDisplayProps { +export interface AgentExecutionDisplayProps { data: TaskResultDisplay; } @@ -32,6 +32,8 @@ const getStatusColor = ( case 'completed': case 'success': return theme.status.success; + case 'cancelled': + return theme.status.warning; case 'failed': return theme.status.error; default: @@ -45,6 +47,8 @@ const getStatusText = (status: TaskResultDisplay['status']) => { return 'Running'; case 'completed': return 'Completed'; + case 'cancelled': + return 'User Cancelled'; case 'failed': return 'Failed'; default: @@ -52,14 +56,17 @@ const getStatusText = (status: TaskResultDisplay['status']) => { } }; +const MAX_TOOL_CALLS = 5; +const MAX_TASK_PROMPT_LINES = 5; + /** * Component to display subagent execution progress and results. * This is now a pure component that renders the provided SubagentExecutionResultDisplay data. * Real-time updates are handled by the parent component updating the data prop. */ -export const SubagentExecutionDisplay: React.FC< - SubagentExecutionDisplayProps -> = ({ data }) => { +export const AgentExecutionDisplay: React.FC = ({ + data, +}) => { const [displayMode, setDisplayMode] = React.useState('default'); const agentColor = useMemo(() => { @@ -76,27 +83,25 @@ export const SubagentExecutionDisplay: React.FC< if (displayMode === 'verbose') return 'Press ctrl+r to show less.'; if (displayMode === 'default') { - const hasMoreLines = data.taskPrompt.split('\n').length > 10; - const hasMoreToolCalls = data.toolCalls && data.toolCalls.length > 5; + const hasMoreLines = + data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES; + const hasMoreToolCalls = + data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS; if (hasMoreToolCalls || hasMoreLines) { - return 'Press ctrl+s to show more.'; + return 'Press ctrl+r to show more.'; } return ''; } return ''; }, [displayMode, data.toolCalls, data.taskPrompt, data.status]); - // Handle ctrl+s and ctrl+r keypresses to control display mode + // Handle ctrl+r keypresses to control display mode useKeypress( (key) => { - if (key.ctrl && key.name === 's') { + if (key.ctrl && key.name === 'r') { setDisplayMode((current) => - current === 'default' ? 'verbose' : 'verbose', - ); - } else if (key.ctrl && key.name === 'r') { - setDisplayMode((current) => - current === 'verbose' ? 'default' : 'default', + current === 'default' ? 'verbose' : 'default', ); } }, @@ -133,7 +138,9 @@ export const SubagentExecutionDisplay: React.FC< )} {/* Results section for completed/failed tasks */} - {(data.status === 'completed' || data.status === 'failed') && ( + {(data.status === 'completed' || + data.status === 'failed' || + data.status === 'cancelled') && ( )} @@ -157,7 +164,7 @@ const TaskPromptSection: React.FC<{ const lines = taskPrompt.split('\n'); const shouldTruncate = lines.length > 10; const showFull = displayMode === 'verbose'; - const displayLines = showFull ? lines : lines.slice(0, 10); + const displayLines = showFull ? lines : lines.slice(0, MAX_TASK_PROMPT_LINES); return ( @@ -206,9 +213,9 @@ const ToolCallsList: React.FC<{ displayMode: DisplayMode; }> = ({ toolCalls, displayMode }) => { const calls = toolCalls || []; - const shouldTruncate = calls.length > 5; + const shouldTruncate = calls.length > MAX_TOOL_CALLS; const showAll = displayMode === 'verbose'; - const displayCalls = showAll ? calls : calls.slice(-5); // Show last 5 + const displayCalls = showAll ? calls : calls.slice(-MAX_TOOL_CALLS); // Show last 5 // Reverse the order to show most recent first const reversedDisplayCalls = [...displayCalls].reverse(); @@ -220,7 +227,7 @@ const ToolCallsList: React.FC<{ {shouldTruncate && displayMode === 'default' && ( {' '} - Showing the last 5 of {calls.length} tools. + Showing the last {MAX_TOOL_CALLS} of {calls.length} tools. )} @@ -390,16 +397,18 @@ const ResultsSection: React.FC<{ )} - {/* Execution Summary section */} - - - Execution Summary: + {/* Execution Summary section - hide when cancelled */} + {data.status !== 'cancelled' && ( + + + Execution Summary: + + - - + )} - {/* Tool Usage section */} - {data.executionSummary && ( + {/* Tool Usage section - hide when cancelled */} + {data.status !== 'cancelled' && data.executionSummary && ( Tool Usage: @@ -409,11 +418,18 @@ const ResultsSection: React.FC<{ )} {/* Error reason for failed tasks */} - {data.status === 'failed' && data.terminateReason && ( + {data.status === 'cancelled' && ( - ❌ Failed: - {data.terminateReason} + ⏹ User Cancelled )} + {data.status === 'failed' && + data.terminateReason && + data.terminateReason !== 'CANCELLED' && ( + + ❌ Failed: + {data.terminateReason} + + )} ); diff --git a/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx b/packages/cli/src/ui/components/subagents/view/ActionSelectionStep.tsx similarity index 94% rename from packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx rename to packages/cli/src/ui/components/subagents/view/ActionSelectionStep.tsx index 8b92ea60..87c37599 100644 --- a/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/view/ActionSelectionStep.tsx @@ -6,8 +6,8 @@ import { useState } from 'react'; import { Box } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { MANAGEMENT_STEPS } from './types.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { MANAGEMENT_STEPS } from '../types.js'; import { SubagentConfig } from '@qwen-code/qwen-code-core'; interface ActionSelectionStepProps { diff --git a/packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx b/packages/cli/src/ui/components/subagents/view/AgentDeleteStep.tsx similarity index 88% rename from packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx rename to packages/cli/src/ui/components/subagents/view/AgentDeleteStep.tsx index 4e6b69e0..697d9719 100644 --- a/packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx +++ b/packages/cli/src/ui/components/subagents/view/AgentDeleteStep.tsx @@ -6,9 +6,9 @@ import { Box, Text } from 'ink'; import { SubagentConfig } from '@qwen-code/qwen-code-core'; -import { StepNavigationProps } from './types.js'; -import { theme } from '../../semantic-colors.js'; -import { useKeypress } from '../../hooks/useKeypress.js'; +import { StepNavigationProps } from '../types.js'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; interface AgentDeleteStepProps extends StepNavigationProps { selectedAgent: SubagentConfig | null; diff --git a/packages/cli/src/ui/components/subagents/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/view/AgentEditStep.tsx similarity index 91% rename from packages/cli/src/ui/components/subagents/AgentEditStep.tsx rename to packages/cli/src/ui/components/subagents/view/AgentEditStep.tsx index 2cc46d70..244a0b2b 100644 --- a/packages/cli/src/ui/components/subagents/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/view/AgentEditStep.tsx @@ -6,10 +6,10 @@ import { useState, useCallback } from 'react'; import { Box, Text } from 'ink'; -import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { MANAGEMENT_STEPS } from './types.js'; -import { theme } from '../../semantic-colors.js'; -import { useLaunchEditor } from './useLaunchEditor.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { MANAGEMENT_STEPS } from '../types.js'; +import { theme } from '../../../semantic-colors.js'; +import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js'; import { SubagentConfig } from '@qwen-code/qwen-code-core'; interface EditOption { diff --git a/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/view/AgentSelectionStep.tsx similarity index 98% rename from packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx rename to packages/cli/src/ui/components/subagents/view/AgentSelectionStep.tsx index d91e6092..d1161e10 100644 --- a/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/view/AgentSelectionStep.tsx @@ -6,9 +6,9 @@ import { useState, useEffect, useMemo } from 'react'; import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { Colors } from '../../colors.js'; -import { useKeypress } from '../../hooks/useKeypress.js'; +import { theme } from '../../../semantic-colors.js'; +import { Colors } from '../../../colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; import { SubagentConfig } from '@qwen-code/qwen-code-core'; interface NavigationState { diff --git a/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx b/packages/cli/src/ui/components/subagents/view/AgentViewerStep.tsx similarity index 92% rename from packages/cli/src/ui/components/subagents/AgentViewerStep.tsx rename to packages/cli/src/ui/components/subagents/view/AgentViewerStep.tsx index 3f6fb0eb..28d20c00 100644 --- a/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx +++ b/packages/cli/src/ui/components/subagents/view/AgentViewerStep.tsx @@ -5,8 +5,8 @@ */ import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { shouldShowColor, getColorForDisplay } from './utils.js'; +import { theme } from '../../../semantic-colors.js'; +import { shouldShowColor, getColorForDisplay } from '../utils.js'; import { SubagentConfig } from '@qwen-code/qwen-code-core'; interface AgentViewerStepProps { diff --git a/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx b/packages/cli/src/ui/components/subagents/view/AgentsManagerDialog.tsx similarity index 97% rename from packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx rename to packages/cli/src/ui/components/subagents/view/AgentsManagerDialog.tsx index fbbfca16..e988b6ee 100644 --- a/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx +++ b/packages/cli/src/ui/components/subagents/view/AgentsManagerDialog.tsx @@ -11,11 +11,11 @@ import { ActionSelectionStep } from './ActionSelectionStep.js'; import { AgentViewerStep } from './AgentViewerStep.js'; import { EditOptionsStep } from './AgentEditStep.js'; import { AgentDeleteStep } from './AgentDeleteStep.js'; -import { ToolSelector } from './ToolSelector.js'; -import { ColorSelector } from './ColorSelector.js'; -import { MANAGEMENT_STEPS } from './types.js'; -import { Colors } from '../../colors.js'; -import { theme } from '../../semantic-colors.js'; +import { ToolSelector } from '../create/ToolSelector.js'; +import { ColorSelector } from '../create/ColorSelector.js'; +import { MANAGEMENT_STEPS } from '../types.js'; +import { Colors } from '../../../colors.js'; +import { theme } from '../../../semantic-colors.js'; import { Config, SubagentConfig } from '@qwen-code/qwen-code-core'; interface AgentsManagerDialogProps { diff --git a/packages/cli/src/ui/components/subagents/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts similarity index 97% rename from packages/cli/src/ui/components/subagents/useLaunchEditor.ts rename to packages/cli/src/ui/hooks/useLaunchEditor.ts index 36171fa1..bce242c8 100644 --- a/packages/cli/src/ui/components/subagents/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -8,7 +8,7 @@ import { useCallback } from 'react'; import { useStdin } from 'ink'; import { EditorType } from '@qwen-code/qwen-code-core'; import { spawnSync } from 'child_process'; -import { useSettings } from '../../contexts/SettingsContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; /** * Determines the editor command to use based on user preferences and platform. diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 1c400d52..c7e5d20c 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -22,6 +22,7 @@ import { Config, Kind, ApprovalMode, + ToolResultDisplay, ToolRegistry, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; @@ -633,6 +634,135 @@ describe('CoreToolScheduler YOLO mode', () => { }); }); +describe('CoreToolScheduler cancellation during executing with live output', () => { + it('sets status to cancelled and preserves last output', async () => { + class StreamingInvocation extends BaseToolInvocation< + { id: string }, + ToolResult + > { + getDescription(): string { + return `Streaming tool ${this.params.id}`; + } + + async execute( + signal: AbortSignal, + updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + updateOutput?.('hello'); + // Wait until aborted to emulate a long-running task + await new Promise((resolve) => { + if (signal.aborted) return resolve(); + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + resolve(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + // Return a normal (non-error) result; scheduler should still mark cancelled + return { llmContent: 'done', returnDisplay: 'done' }; + } + } + + class StreamingTool extends BaseDeclarativeTool< + { id: string }, + ToolResult + > { + constructor() { + super( + 'stream-tool', + 'Stream Tool', + 'Emits live output and waits for abort', + Kind.Other, + { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + true, + true, + ); + } + protected createInvocation(params: { id: string }) { + return new StreamingInvocation(params); + } + } + + const tool = new StreamingTool(); + const mockToolRegistry = { + getTool: () => tool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => tool, + getToolByDisplayName: () => tool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + toolRegistry: mockToolRegistry, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'stream-tool', + args: { id: 'x' }, + isClientInitiated: true, + prompt_id: 'prompt-stream', + }; + + const schedulePromise = scheduler.schedule( + [request], + abortController.signal, + ); + + // Wait until executing + await vi.waitFor(() => { + const calls = onToolCallsUpdate.mock.calls; + const last = calls[calls.length - 1]?.[0][0] as ToolCall | undefined; + expect(last?.status).toBe('executing'); + }); + + // Now abort + abortController.abort(); + + await schedulePromise; + + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls[0].status).toBe('cancelled'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cancelled: any = completedCalls[0]; + expect(cancelled.response.resultDisplay).toBe('hello'); + }); +}); + describe('CoreToolScheduler request queueing', () => { it('should queue a request if another is running', async () => { let resolveFirstCall: (result: ToolResult) => void; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index adf7086f..f6480c02 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -374,6 +374,13 @@ export class CoreToolScheduler { newContent: waitingCall.confirmationDetails.newContent, }; } + } else if (currentCall.status === 'executing') { + // If the tool was streaming live output, preserve the latest + // output so the UI can continue to show it after cancellation. + const executingCall = currentCall as ExecutingToolCall; + if (executingCall.liveOutput !== undefined) { + resultDisplay = executingCall.liveOutput; + } } return { @@ -816,20 +823,19 @@ export class CoreToolScheduler { const invocation = scheduledCall.invocation; this.setStatusInternal(callId, 'executing'); - const liveOutputCallback = - scheduledCall.tool.canUpdateOutput && this.outputUpdateHandler - ? (outputChunk: ToolResultDisplay) => { - if (this.outputUpdateHandler) { - this.outputUpdateHandler(callId, outputChunk); - } - this.toolCalls = this.toolCalls.map((tc) => - tc.request.callId === callId && tc.status === 'executing' - ? { ...tc, liveOutput: outputChunk } - : tc, - ); - this.notifyToolCallsUpdate(); + const liveOutputCallback = scheduledCall.tool.canUpdateOutput + ? (outputChunk: ToolResultDisplay) => { + if (this.outputUpdateHandler) { + this.outputUpdateHandler(callId, outputChunk); } - : undefined; + this.toolCalls = this.toolCalls.map((tc) => + tc.request.callId === callId && tc.status === 'executing' + ? { ...tc, liveOutput: outputChunk } + : tc, + ); + this.notifyToolCallsUpdate(); + } + : undefined; invocation .execute(signal, liveOutputCallback) diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index edc006df..11968026 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -49,7 +49,6 @@ export type { RunConfig, ToolConfig, SubagentTerminateMode, - OutputObject, } from './types.js'; export { SubAgentScope } from './subagent.js'; diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index 2d44e789..25b817ec 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -412,7 +412,7 @@ describe('subagent.ts', () => { await expect(scope.runNonInteractive(context)).rejects.toThrow( 'Missing context values for the following keys: missing', ); - expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); }); it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => { @@ -434,7 +434,7 @@ describe('subagent.ts', () => { await expect(agent.runNonInteractive(context)).rejects.toThrow( 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', ); - expect(agent.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + expect(agent.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); }); }); @@ -457,8 +457,7 @@ describe('subagent.ts', () => { await scope.runNonInteractive(new ContextState()); - expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); - expect(scope.output.result).toBe('Done.'); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); // Check the initial message expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([ @@ -482,8 +481,7 @@ describe('subagent.ts', () => { await scope.runNonInteractive(new ContextState()); - expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); - expect(scope.output.result).toBe('Done.'); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); @@ -549,7 +547,7 @@ describe('subagent.ts', () => { { text: 'file1.txt\nfile2.ts' }, ]); - expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); }); it('should provide specific tool error responses to the model', async () => { @@ -645,9 +643,7 @@ describe('subagent.ts', () => { await scope.runNonInteractive(new ContextState()); expect(mockSendMessageStream).toHaveBeenCalledTimes(2); - expect(scope.output.terminate_reason).toBe( - SubagentTerminateMode.MAX_TURNS, - ); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.MAX_TURNS); }); it('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => { @@ -690,9 +686,7 @@ describe('subagent.ts', () => { await runPromise; - expect(scope.output.terminate_reason).toBe( - SubagentTerminateMode.TIMEOUT, - ); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.TIMEOUT); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); vi.useRealTimers(); @@ -713,7 +707,7 @@ describe('subagent.ts', () => { await expect( scope.runNonInteractive(new ContextState()), ).rejects.toThrow('API Failure'); - expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); }); }); }); diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 1b435b9d..69b7f8b5 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -20,7 +20,6 @@ import { } from '@google/genai'; import { GeminiChat } from '../core/geminiChat.js'; import { - OutputObject, SubagentTerminateMode, PromptConfig, ModelConfig, @@ -150,10 +149,6 @@ function templateString(template: string, context: ContextState): string { * runtime context, and the collection of its outputs. */ export class SubAgentScope { - output: OutputObject = { - terminate_reason: SubagentTerminateMode.ERROR, - result: '', - }; executionStats: ExecutionStats = { startTimeMs: 0, totalDurationMs: 0, @@ -179,6 +174,7 @@ export class SubAgentScope { >(); private eventEmitter?: SubAgentEventEmitter; private finalText: string = ''; + private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR; private readonly stats = new SubagentStatistics(); private hooks?: SubagentHooks; private readonly subagentId: string; @@ -312,14 +308,18 @@ export class SubAgentScope { const chat = await this.createChatObject(context); if (!chat) { - this.output.terminate_reason = SubagentTerminateMode.ERROR; + this.terminateMode = SubagentTerminateMode.ERROR; return; } const abortController = new AbortController(); const onAbort = () => abortController.abort(); if (externalSignal) { - if (externalSignal.aborted) abortController.abort(); + if (externalSignal.aborted) { + abortController.abort(); + this.terminateMode = SubagentTerminateMode.CANCELLED; + return; + } externalSignal.addEventListener('abort', onAbort, { once: true }); } const toolRegistry = this.runtimeContext.getToolRegistry(); @@ -381,7 +381,7 @@ export class SubAgentScope { this.runConfig.max_turns && turnCounter >= this.runConfig.max_turns ) { - this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS; + this.terminateMode = SubagentTerminateMode.MAX_TURNS; break; } let durationMin = (Date.now() - startTime) / (1000 * 60); @@ -389,7 +389,7 @@ export class SubAgentScope { this.runConfig.max_time_minutes && durationMin >= this.runConfig.max_time_minutes ) { - this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; + this.terminateMode = SubagentTerminateMode.TIMEOUT; break; } @@ -418,7 +418,10 @@ export class SubAgentScope { let lastUsage: GenerateContentResponseUsageMetadata | undefined = undefined; for await (const resp of responseStream) { - if (abortController.signal.aborted) return; + if (abortController.signal.aborted) { + this.terminateMode = SubagentTerminateMode.CANCELLED; + return; + } if (resp.functionCalls) functionCalls.push(...resp.functionCalls); const content = resp.candidates?.[0]?.content; const parts = content?.parts || []; @@ -443,7 +446,7 @@ export class SubAgentScope { this.runConfig.max_time_minutes && durationMin >= this.runConfig.max_time_minutes ) { - this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; + this.terminateMode = SubagentTerminateMode.TIMEOUT; break; } @@ -483,8 +486,7 @@ export class SubAgentScope { // No tool calls — treat this as the model's final answer. if (roundText && roundText.trim().length > 0) { this.finalText = roundText.trim(); - this.output.result = this.finalText; - this.output.terminate_reason = SubagentTerminateMode.GOAL; + this.terminateMode = SubagentTerminateMode.GOAL; break; } // Otherwise, nudge the model to finalize a result. @@ -508,7 +510,7 @@ export class SubAgentScope { } } catch (error) { console.error('Error during subagent execution:', error); - this.output.terminate_reason = SubagentTerminateMode.ERROR; + this.terminateMode = SubagentTerminateMode.ERROR; this.eventEmitter?.emit(SubAgentEventType.ERROR, { subagentId: this.subagentId, error: error instanceof Error ? error.message : String(error), @@ -529,7 +531,7 @@ export class SubAgentScope { const summary = this.stats.getSummary(Date.now()); this.eventEmitter?.emit(SubAgentEventType.FINISH, { subagentId: this.subagentId, - terminate_reason: this.output.terminate_reason, + terminate_reason: this.terminateMode, timestamp: Date.now(), rounds: summary.rounds, totalDurationMs: summary.totalDurationMs, @@ -541,14 +543,13 @@ export class SubAgentScope { totalTokens: summary.totalTokens, } as SubAgentFinishEvent); - // Log telemetry for subagent completion const completionEvent = new SubagentExecutionEvent( this.name, - this.output.terminate_reason === SubagentTerminateMode.GOAL + this.terminateMode === SubagentTerminateMode.GOAL ? 'completed' : 'failed', { - terminate_reason: this.output.terminate_reason, + terminate_reason: this.terminateMode, result: this.finalText, execution_summary: this.stats.formatCompact( 'Subagent execution completed', @@ -560,7 +561,7 @@ export class SubAgentScope { await this.hooks?.onStop?.({ subagentId: this.subagentId, name: this.name, - terminateReason: this.output.terminate_reason, + terminateReason: this.terminateMode, summary: summary as unknown as Record, timestamp: Date.now(), }); @@ -751,6 +752,10 @@ export class SubAgentScope { return this.finalText; } + getTerminateMode(): SubagentTerminateMode { + return this.terminateMode; + } + private async createChatObject(context: ContextState) { if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { throw new Error( diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 4f7b420f..b8146125 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -183,24 +183,10 @@ export enum SubagentTerminateMode { * Indicates that the subagent's execution terminated because it exceeded the maximum number of turns. */ MAX_TURNS = 'MAX_TURNS', -} - -/** - * Represents the output structure of a subagent's execution. - * This interface defines the data that a subagent will return upon completion, - * including the final result and the reason for its termination. - */ -export interface OutputObject { /** - * The final result text returned by the subagent upon completion. - * This contains the direct output from the model's final response. + * Indicates that the subagent's execution was cancelled via an abort signal. */ - result: string; - /** - * The reason for the subagent's termination, indicating whether it completed - * successfully, timed out, or encountered an error. - */ - terminate_reason: SubagentTerminateMode; + CANCELLED = 'CANCELLED', } /** diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 23ec5802..af7f9ad7 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -294,7 +294,7 @@ export function recordContentRetryFailure(config: Config): void { export function recordSubagentExecutionMetrics( config: Config, subagentName: string, - status: 'started' | 'progress' | 'completed' | 'failed', + status: 'started' | 'completed' | 'failed' | 'cancelled', terminateReason?: string, ): void { if (!subagentExecutionCounter || !isMetricsInitialized) return; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index b5c0e051..a39580e2 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -448,14 +448,14 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent { 'event.name': 'subagent_execution'; 'event.timestamp': string; subagent_name: string; - status: 'started' | 'progress' | 'completed' | 'failed'; + status: 'started' | 'completed' | 'failed' | 'cancelled'; terminate_reason?: string; result?: string; execution_summary?: string; constructor( subagent_name: string, - status: 'started' | 'progress' | 'completed' | 'failed', + status: 'started' | 'completed' | 'failed' | 'cancelled', options?: { terminate_reason?: string; result?: string; diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts index 85688cbf..5afa4e8f 100644 --- a/packages/core/src/tools/task.test.ts +++ b/packages/core/src/tools/task.test.ts @@ -258,10 +258,8 @@ describe('TaskTool', () => { beforeEach(() => { mockSubagentScope = { runNonInteractive: vi.fn().mockResolvedValue(undefined), - output: { - result: 'Task completed successfully', - terminate_reason: SubagentTerminateMode.GOAL, - }, + result: 'Task completed successfully', + terminateMode: SubagentTerminateMode.GOAL, getFinalText: vi.fn().mockReturnValue('Task completed successfully'), formatCompactResult: vi .fn() @@ -305,6 +303,7 @@ describe('TaskTool', () => { successfulToolCalls: 3, failedToolCalls: 0, }), + getTerminateMode: vi.fn().mockReturnValue(SubagentTerminateMode.GOAL), } as unknown as SubAgentScope; mockContextState = { @@ -375,25 +374,6 @@ describe('TaskTool', () => { expect(display.subagentName).toBe('non-existent'); }); - it('should handle subagent execution failure', async () => { - mockSubagentScope.output.terminate_reason = SubagentTerminateMode.ERROR; - - const params: TaskParams = { - description: 'Search files', - prompt: 'Find all TypeScript files', - subagent_type: 'file-search', - }; - - const invocation = ( - taskTool as TaskToolWithProtectedMethods - ).createInvocation(params); - const result = await invocation.execute(); - - const display = result.returnDisplay as TaskResultDisplay; - expect(display.status).toBe('failed'); - expect(display.terminateReason).toBe('ERROR'); - }); - it('should handle execution errors gracefully', async () => { vi.mocked(mockSubagentManager.createSubagentScope).mockRejectedValue( new Error('Creation failed'), diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 7a69bf2c..1ea73cbc 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -14,7 +14,7 @@ import { } from './tools.js'; import { Config } from '../config/config.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; -import { SubagentConfig } from '../subagents/types.js'; +import { SubagentConfig, SubagentTerminateMode } from '../subagents/types.js'; import { ContextState } from '../subagents/subagent.js'; import { SubAgentEventEmitter, @@ -409,21 +409,6 @@ class TaskToolInvocation extends BaseToolInvocation { // Set up event listeners for real-time updates this.setupEventListeners(updateOutput); - if (signal) { - signal.addEventListener('abort', () => { - if (this.currentDisplay) { - this.updateDisplay( - { - status: 'failed', - terminateReason: 'CANCELLED', - result: 'Task was cancelled by user', - }, - updateOutput, - ); - } - }); - } - // Send initial display if (updateOutput) { updateOutput(this.currentDisplay); @@ -474,20 +459,31 @@ class TaskToolInvocation extends BaseToolInvocation { // Get the results const finalText = subagentScope.getFinalText(); - const terminateReason = subagentScope.output.terminate_reason; - const success = terminateReason === 'GOAL'; + const terminateReason = subagentScope.getTerminateMode(); + const success = terminateReason === SubagentTerminateMode.GOAL; const executionSummary = subagentScope.getExecutionSummary(); - // Update the final display state - this.updateDisplay( - { - status: success ? 'completed' : 'failed', - terminateReason, - result: finalText, - executionSummary, - }, - updateOutput, - ); + if (signal?.aborted) { + this.updateDisplay( + { + status: 'cancelled', + terminateReason: 'CANCELLED', + result: finalText || 'Task was cancelled by user', + executionSummary, + }, + updateOutput, + ); + } else { + this.updateDisplay( + { + status: success ? 'completed' : 'failed', + terminateReason, + result: finalText, + executionSummary, + }, + updateOutput, + ); + } return { llmContent: [{ text: finalText }], @@ -500,7 +496,7 @@ class TaskToolInvocation extends BaseToolInvocation { const errorDisplay: TaskResultDisplay = { ...this.currentDisplay!, - status: 'failed' as const, + status: 'failed', terminateReason: 'ERROR', result: `Failed to run subagent: ${errorMessage}`, }; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index abd2980b..b8a5e9bb 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -428,7 +428,7 @@ export interface TaskResultDisplay { subagentColor?: string; taskDescription: string; taskPrompt: string; - status: 'running' | 'completed' | 'failed'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; terminateReason?: string; result?: string; executionSummary?: SubagentStatsSummary;