feat: subagent feature wip

This commit is contained in:
tanzhenxin
2025-09-10 13:41:28 +08:00
parent 549f296eb5
commit 6b09aee32b
30 changed files with 329 additions and 239 deletions

View File

@@ -44,7 +44,7 @@ import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { import {
SubagentCreationWizard, AgentCreationWizard,
AgentsManagerDialog, AgentsManagerDialog,
} from './components/subagents/index.js'; } from './components/subagents/index.js';
import { Colors } from './colors.js'; import { Colors } from './colors.js';
@@ -1093,7 +1093,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box> </Box>
) : isSubagentCreateDialogOpen ? ( ) : isSubagentCreateDialogOpen ? (
<Box flexDirection="column"> <Box flexDirection="column">
<SubagentCreationWizard <AgentCreationWizard
onClose={closeSubagentCreateDialog} onClose={closeSubagentCreateDialog}
config={config} config={config}
/> />

View File

@@ -40,7 +40,7 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({
}, },
})); }));
vi.mock('../subagents/index.js', () => ({ vi.mock('../subagents/index.js', () => ({
SubagentExecutionDisplay: function MockSubagentExecutionDisplay({ AgentExecutionDisplay: function MockAgentExecutionDisplay({
data, data,
}: { }: {
data: { subagentName: string; taskDescription: string }; data: { subagentName: string; taskDescription: string };

View File

@@ -17,7 +17,7 @@ import {
TodoResultDisplay, TodoResultDisplay,
TaskResultDisplay, TaskResultDisplay,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { SubagentExecutionDisplay } from '../subagents/index.js'; import { AgentExecutionDisplay } from '../subagents/index.js';
const STATIC_HEIGHT = 1; const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -106,7 +106,7 @@ const SubagentExecutionRenderer: React.FC<{
data: TaskResultDisplay; data: TaskResultDisplay;
availableHeight?: number; availableHeight?: number;
childWidth: number; childWidth: number;
}> = ({ data }) => <SubagentExecutionDisplay data={data} />; }> = ({ data }) => <AgentExecutionDisplay data={data} />;
/** /**
* Component to render string results (markdown or plain text) * Component to render string results (markdown or plain text)

View File

@@ -6,20 +6,20 @@
import { useReducer, useCallback, useMemo } from 'react'; import { useReducer, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { wizardReducer, initialWizardState } from './reducers.js'; import { wizardReducer, initialWizardState } from '../reducers.js';
import { LocationSelector } from './LocationSelector.js'; import { LocationSelector } from './LocationSelector.js';
import { GenerationMethodSelector } from './GenerationMethodSelector.js'; import { GenerationMethodSelector } from './GenerationMethodSelector.js';
import { DescriptionInput } from './DescriptionInput.js'; import { DescriptionInput } from './DescriptionInput.js';
import { ToolSelector } from './ToolSelector.js'; import { ToolSelector } from './ToolSelector.js';
import { ColorSelector } from './ColorSelector.js'; import { ColorSelector } from './ColorSelector.js';
import { CreationSummary } from './CreationSummary.js'; import { CreationSummary } from './CreationSummary.js';
import { WizardStepProps } from './types.js'; import { WizardStepProps } from '../types.js';
import { WIZARD_STEPS } from './constants.js'; import { WIZARD_STEPS } from '../constants.js';
import { Config } from '@qwen-code/qwen-code-core'; import { Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
interface SubagentCreationWizardProps { interface AgentCreationWizardProps {
onClose: () => void; onClose: () => void;
config: Config | null; config: Config | null;
} }
@@ -27,10 +27,10 @@ interface SubagentCreationWizardProps {
/** /**
* Main orchestrator component for the subagent creation wizard. * Main orchestrator component for the subagent creation wizard.
*/ */
export function SubagentCreationWizard({ export function AgentCreationWizard({
onClose, onClose,
config, config,
}: SubagentCreationWizardProps) { }: AgentCreationWizardProps) {
const [state, dispatch] = useReducer(wizardReducer, initialWizardState); const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
const handleNext = useCallback(() => { const handleNext = useCallback(() => {

View File

@@ -6,10 +6,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { ColorOption } from './types.js'; import { ColorOption } from '../types.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
import { COLOR_OPTIONS } from './constants.js'; import { COLOR_OPTIONS } from '../constants.js';
const colorOptions: ColorOption[] = COLOR_OPTIONS; const colorOptions: ColorOption[] = COLOR_OPTIONS;

View File

@@ -6,11 +6,11 @@
import { useCallback, useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink'; 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 { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js'; import { shouldShowColor, getColorForDisplay } from '../utils.js';
import { useLaunchEditor } from './useLaunchEditor.js'; import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js';
/** /**
* Step 6: Final confirmation and actions. * Step 6: Final confirmation and actions.

View File

@@ -6,17 +6,17 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { WizardStepProps, WizardAction } from './types.js'; import { WizardStepProps, WizardAction } from '../types.js';
import { sanitizeInput } from './utils.js'; import { sanitizeInput } from '../utils.js';
import { Config, subagentGenerator } from '@qwen-code/qwen-code-core'; import { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
import { useTextBuffer } from '../shared/text-buffer.js'; import { useTextBuffer } from '../../shared/text-buffer.js';
import { useKeypress, Key } from '../../hooks/useKeypress.js'; import { useKeypress, Key } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js'; import { keyMatchers, Command } from '../../../keyMatchers.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { cpSlice, cpLen } from '../../utils/textUtils.js'; import { cpSlice, cpLen } from '../../../utils/textUtils.js';
import chalk from 'chalk'; import chalk from 'chalk';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
/** /**
* Step 3: Description input with LLM generation. * Step 3: Description input with LLM generation.

View File

@@ -5,8 +5,8 @@
*/ */
import { Box } from 'ink'; import { Box } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { WizardStepProps } from './types.js'; import { WizardStepProps } from '../types.js';
interface GenerationOption { interface GenerationOption {
label: string; label: string;

View File

@@ -5,8 +5,8 @@
*/ */
import { Box } from 'ink'; import { Box } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { WizardStepProps } from './types.js'; import { WizardStepProps } from '../types.js';
interface LocationOption { interface LocationOption {
label: string; label: string;

View File

@@ -6,10 +6,10 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { ToolCategory } from './types.js'; import { ToolCategory } from '../types.js';
import { Kind, Config } from '@qwen-code/qwen-code-core'; import { Kind, Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
interface ToolOption { interface ToolOption {
label: string; label: string;

View File

@@ -4,33 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
// Creation Wizard Components // Creation Wizard
export { SubagentCreationWizard } from './SubagentCreationWizard.js'; export { AgentCreationWizard } from './create/AgentCreationWizard.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';
// Management Dialog Components // Management Dialog
export { AgentsManagerDialog } from './AgentsManagerDialog.js'; export { AgentsManagerDialog } from './view/AgentsManagerDialog.js';
export { AgentSelectionStep } from './AgentSelectionStep.js';
export { ActionSelectionStep } from './ActionSelectionStep.js';
export { AgentViewerStep } from './AgentViewerStep.js';
export { AgentDeleteStep } from './AgentDeleteStep.js';
// Execution Display Components // Execution Display
export { SubagentExecutionDisplay } from './SubagentExecutionDisplay.js'; export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js';
// Creation Wizard Types and State
export type {
CreationWizardState,
WizardAction,
WizardStepProps,
WizardResult,
ToolCategory,
ColorOption,
} from './types.js';
export { wizardReducer, initialWizardState } from './reducers.js';

View File

@@ -6,19 +6,19 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
import { import {
TaskResultDisplay, TaskResultDisplay,
SubagentStatsSummary, SubagentStatsSummary,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../../hooks/useKeypress.js';
import { COLOR_OPTIONS } from './constants.js'; import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from './utils.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; data: TaskResultDisplay;
} }
@@ -32,6 +32,8 @@ const getStatusColor = (
case 'completed': case 'completed':
case 'success': case 'success':
return theme.status.success; return theme.status.success;
case 'cancelled':
return theme.status.warning;
case 'failed': case 'failed':
return theme.status.error; return theme.status.error;
default: default:
@@ -45,6 +47,8 @@ const getStatusText = (status: TaskResultDisplay['status']) => {
return 'Running'; return 'Running';
case 'completed': case 'completed':
return 'Completed'; return 'Completed';
case 'cancelled':
return 'User Cancelled';
case 'failed': case 'failed':
return 'Failed'; return 'Failed';
default: 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. * Component to display subagent execution progress and results.
* This is now a pure component that renders the provided SubagentExecutionResultDisplay data. * 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. * Real-time updates are handled by the parent component updating the data prop.
*/ */
export const SubagentExecutionDisplay: React.FC< export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
SubagentExecutionDisplayProps data,
> = ({ data }) => { }) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default'); const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
const agentColor = useMemo(() => { const agentColor = useMemo(() => {
@@ -76,27 +83,25 @@ export const SubagentExecutionDisplay: React.FC<
if (displayMode === 'verbose') return 'Press ctrl+r to show less.'; if (displayMode === 'verbose') return 'Press ctrl+r to show less.';
if (displayMode === 'default') { if (displayMode === 'default') {
const hasMoreLines = data.taskPrompt.split('\n').length > 10; const hasMoreLines =
const hasMoreToolCalls = data.toolCalls && data.toolCalls.length > 5; data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
const hasMoreToolCalls =
data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS;
if (hasMoreToolCalls || hasMoreLines) { if (hasMoreToolCalls || hasMoreLines) {
return 'Press ctrl+s to show more.'; return 'Press ctrl+r to show more.';
} }
return ''; return '';
} }
return ''; return '';
}, [displayMode, data.toolCalls, data.taskPrompt, data.status]); }, [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( useKeypress(
(key) => { (key) => {
if (key.ctrl && key.name === 's') { if (key.ctrl && key.name === 'r') {
setDisplayMode((current) => setDisplayMode((current) =>
current === 'default' ? 'verbose' : 'verbose', current === 'default' ? 'verbose' : 'default',
);
} else if (key.ctrl && key.name === 'r') {
setDisplayMode((current) =>
current === 'verbose' ? 'default' : 'default',
); );
} }
}, },
@@ -133,7 +138,9 @@ export const SubagentExecutionDisplay: React.FC<
)} )}
{/* Results section for completed/failed tasks */} {/* Results section for completed/failed tasks */}
{(data.status === 'completed' || data.status === 'failed') && ( {(data.status === 'completed' ||
data.status === 'failed' ||
data.status === 'cancelled') && (
<ResultsSection data={data} displayMode={displayMode} /> <ResultsSection data={data} displayMode={displayMode} />
)} )}
@@ -157,7 +164,7 @@ const TaskPromptSection: React.FC<{
const lines = taskPrompt.split('\n'); const lines = taskPrompt.split('\n');
const shouldTruncate = lines.length > 10; const shouldTruncate = lines.length > 10;
const showFull = displayMode === 'verbose'; const showFull = displayMode === 'verbose';
const displayLines = showFull ? lines : lines.slice(0, 10); const displayLines = showFull ? lines : lines.slice(0, MAX_TASK_PROMPT_LINES);
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
@@ -206,9 +213,9 @@ const ToolCallsList: React.FC<{
displayMode: DisplayMode; displayMode: DisplayMode;
}> = ({ toolCalls, displayMode }) => { }> = ({ toolCalls, displayMode }) => {
const calls = toolCalls || []; const calls = toolCalls || [];
const shouldTruncate = calls.length > 5; const shouldTruncate = calls.length > MAX_TOOL_CALLS;
const showAll = displayMode === 'verbose'; 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 // Reverse the order to show most recent first
const reversedDisplayCalls = [...displayCalls].reverse(); const reversedDisplayCalls = [...displayCalls].reverse();
@@ -220,7 +227,7 @@ const ToolCallsList: React.FC<{
{shouldTruncate && displayMode === 'default' && ( {shouldTruncate && displayMode === 'default' && (
<Text color={Colors.Gray}> <Text color={Colors.Gray}>
{' '} {' '}
Showing the last 5 of {calls.length} tools. Showing the last {MAX_TOOL_CALLS} of {calls.length} tools.
</Text> </Text>
)} )}
</Box> </Box>
@@ -390,16 +397,18 @@ const ResultsSection: React.FC<{
<ToolCallsList toolCalls={data.toolCalls} displayMode={displayMode} /> <ToolCallsList toolCalls={data.toolCalls} displayMode={displayMode} />
)} )}
{/* Execution Summary section */} {/* Execution Summary section - hide when cancelled */}
<Box flexDirection="column"> {data.status !== 'cancelled' && (
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="column">
<Text color={theme.text.primary}>Execution Summary:</Text> <Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Execution Summary:</Text>
</Box>
<ExecutionSummaryDetails data={data} displayMode={displayMode} />
</Box> </Box>
<ExecutionSummaryDetails data={data} displayMode={displayMode} /> )}
</Box>
{/* Tool Usage section */} {/* Tool Usage section - hide when cancelled */}
{data.executionSummary && ( {data.status !== 'cancelled' && data.executionSummary && (
<Box flexDirection="column"> <Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tool Usage:</Text> <Text color={theme.text.primary}>Tool Usage:</Text>
@@ -409,11 +418,18 @@ const ResultsSection: React.FC<{
)} )}
{/* Error reason for failed tasks */} {/* Error reason for failed tasks */}
{data.status === 'failed' && data.terminateReason && ( {data.status === 'cancelled' && (
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={Colors.AccentRed}> Failed: </Text> <Text color={theme.status.warning}> User Cancelled</Text>
<Text color={Colors.Gray}>{data.terminateReason}</Text>
</Box> </Box>
)} )}
{data.status === 'failed' &&
data.terminateReason &&
data.terminateReason !== 'CANCELLED' && (
<Box flexDirection="row">
<Text color={Colors.AccentRed}> Failed: </Text>
<Text color={Colors.Gray}>{data.terminateReason}</Text>
</Box>
)}
</Box> </Box>
); );

View File

@@ -6,8 +6,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Box } from 'ink'; import { Box } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { MANAGEMENT_STEPS } from './types.js'; import { MANAGEMENT_STEPS } from '../types.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core'; import { SubagentConfig } from '@qwen-code/qwen-code-core';
interface ActionSelectionStepProps { interface ActionSelectionStepProps {

View File

@@ -6,9 +6,9 @@
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { SubagentConfig } from '@qwen-code/qwen-code-core'; import { SubagentConfig } from '@qwen-code/qwen-code-core';
import { StepNavigationProps } from './types.js'; import { StepNavigationProps } from '../types.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../../hooks/useKeypress.js';
interface AgentDeleteStepProps extends StepNavigationProps { interface AgentDeleteStepProps extends StepNavigationProps {
selectedAgent: SubagentConfig | null; selectedAgent: SubagentConfig | null;

View File

@@ -6,10 +6,10 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { MANAGEMENT_STEPS } from './types.js'; import { MANAGEMENT_STEPS } from '../types.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { useLaunchEditor } from './useLaunchEditor.js'; import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core'; import { SubagentConfig } from '@qwen-code/qwen-code-core';
interface EditOption { interface EditOption {

View File

@@ -6,9 +6,9 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../../hooks/useKeypress.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core'; import { SubagentConfig } from '@qwen-code/qwen-code-core';
interface NavigationState { interface NavigationState {

View File

@@ -5,8 +5,8 @@
*/ */
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js'; import { shouldShowColor, getColorForDisplay } from '../utils.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core'; import { SubagentConfig } from '@qwen-code/qwen-code-core';
interface AgentViewerStepProps { interface AgentViewerStepProps {

View File

@@ -11,11 +11,11 @@ import { ActionSelectionStep } from './ActionSelectionStep.js';
import { AgentViewerStep } from './AgentViewerStep.js'; import { AgentViewerStep } from './AgentViewerStep.js';
import { EditOptionsStep } from './AgentEditStep.js'; import { EditOptionsStep } from './AgentEditStep.js';
import { AgentDeleteStep } from './AgentDeleteStep.js'; import { AgentDeleteStep } from './AgentDeleteStep.js';
import { ToolSelector } from './ToolSelector.js'; import { ToolSelector } from '../create/ToolSelector.js';
import { ColorSelector } from './ColorSelector.js'; import { ColorSelector } from '../create/ColorSelector.js';
import { MANAGEMENT_STEPS } from './types.js'; import { MANAGEMENT_STEPS } from '../types.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../../colors.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../../semantic-colors.js';
import { Config, SubagentConfig } from '@qwen-code/qwen-code-core'; import { Config, SubagentConfig } from '@qwen-code/qwen-code-core';
interface AgentsManagerDialogProps { interface AgentsManagerDialogProps {

View File

@@ -8,7 +8,7 @@ import { useCallback } from 'react';
import { useStdin } from 'ink'; import { useStdin } from 'ink';
import { EditorType } from '@qwen-code/qwen-code-core'; import { EditorType } from '@qwen-code/qwen-code-core';
import { spawnSync } from 'child_process'; 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. * Determines the editor command to use based on user preferences and platform.

View File

@@ -22,6 +22,7 @@ import {
Config, Config,
Kind, Kind,
ApprovalMode, ApprovalMode,
ToolResultDisplay,
ToolRegistry, ToolRegistry,
} from '../index.js'; } from '../index.js';
import { Part, PartListUnion } from '@google/genai'; 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<ToolResult> {
updateOutput?.('hello');
// Wait until aborted to emulate a long-running task
await new Promise<void>((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', () => { describe('CoreToolScheduler request queueing', () => {
it('should queue a request if another is running', async () => { it('should queue a request if another is running', async () => {
let resolveFirstCall: (result: ToolResult) => void; let resolveFirstCall: (result: ToolResult) => void;

View File

@@ -374,6 +374,13 @@ export class CoreToolScheduler {
newContent: waitingCall.confirmationDetails.newContent, 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 { return {
@@ -816,20 +823,19 @@ export class CoreToolScheduler {
const invocation = scheduledCall.invocation; const invocation = scheduledCall.invocation;
this.setStatusInternal(callId, 'executing'); this.setStatusInternal(callId, 'executing');
const liveOutputCallback = const liveOutputCallback = scheduledCall.tool.canUpdateOutput
scheduledCall.tool.canUpdateOutput && this.outputUpdateHandler ? (outputChunk: ToolResultDisplay) => {
? (outputChunk: ToolResultDisplay) => { if (this.outputUpdateHandler) {
if (this.outputUpdateHandler) { this.outputUpdateHandler(callId, outputChunk);
this.outputUpdateHandler(callId, outputChunk);
}
this.toolCalls = this.toolCalls.map((tc) =>
tc.request.callId === callId && tc.status === 'executing'
? { ...tc, liveOutput: outputChunk }
: tc,
);
this.notifyToolCallsUpdate();
} }
: undefined; this.toolCalls = this.toolCalls.map((tc) =>
tc.request.callId === callId && tc.status === 'executing'
? { ...tc, liveOutput: outputChunk }
: tc,
);
this.notifyToolCallsUpdate();
}
: undefined;
invocation invocation
.execute(signal, liveOutputCallback) .execute(signal, liveOutputCallback)

View File

@@ -49,7 +49,6 @@ export type {
RunConfig, RunConfig,
ToolConfig, ToolConfig,
SubagentTerminateMode, SubagentTerminateMode,
OutputObject,
} from './types.js'; } from './types.js';
export { SubAgentScope } from './subagent.js'; export { SubAgentScope } from './subagent.js';

View File

@@ -412,7 +412,7 @@ describe('subagent.ts', () => {
await expect(scope.runNonInteractive(context)).rejects.toThrow( await expect(scope.runNonInteractive(context)).rejects.toThrow(
'Missing context values for the following keys: missing', '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 () => { 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( await expect(agent.runNonInteractive(context)).rejects.toThrow(
'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', '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()); await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(mockSendMessageStream).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
// Check the initial message // Check the initial message
expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([ expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([
@@ -482,8 +481,7 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState()); await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(mockSendMessageStream).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
}); });
@@ -549,7 +547,7 @@ describe('subagent.ts', () => {
{ text: 'file1.txt\nfile2.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 () => { it('should provide specific tool error responses to the model', async () => {
@@ -645,9 +643,7 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState()); await scope.runNonInteractive(new ContextState());
expect(mockSendMessageStream).toHaveBeenCalledTimes(2); expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(scope.output.terminate_reason).toBe( expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.MAX_TURNS);
SubagentTerminateMode.MAX_TURNS,
);
}); });
it('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => { 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; await runPromise;
expect(scope.output.terminate_reason).toBe( expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.TIMEOUT);
SubagentTerminateMode.TIMEOUT,
);
expect(mockSendMessageStream).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
vi.useRealTimers(); vi.useRealTimers();
@@ -713,7 +707,7 @@ describe('subagent.ts', () => {
await expect( await expect(
scope.runNonInteractive(new ContextState()), scope.runNonInteractive(new ContextState()),
).rejects.toThrow('API Failure'); ).rejects.toThrow('API Failure');
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR);
}); });
}); });
}); });

View File

@@ -20,7 +20,6 @@ import {
} from '@google/genai'; } from '@google/genai';
import { GeminiChat } from '../core/geminiChat.js'; import { GeminiChat } from '../core/geminiChat.js';
import { import {
OutputObject,
SubagentTerminateMode, SubagentTerminateMode,
PromptConfig, PromptConfig,
ModelConfig, ModelConfig,
@@ -150,10 +149,6 @@ function templateString(template: string, context: ContextState): string {
* runtime context, and the collection of its outputs. * runtime context, and the collection of its outputs.
*/ */
export class SubAgentScope { export class SubAgentScope {
output: OutputObject = {
terminate_reason: SubagentTerminateMode.ERROR,
result: '',
};
executionStats: ExecutionStats = { executionStats: ExecutionStats = {
startTimeMs: 0, startTimeMs: 0,
totalDurationMs: 0, totalDurationMs: 0,
@@ -179,6 +174,7 @@ export class SubAgentScope {
>(); >();
private eventEmitter?: SubAgentEventEmitter; private eventEmitter?: SubAgentEventEmitter;
private finalText: string = ''; private finalText: string = '';
private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR;
private readonly stats = new SubagentStatistics(); private readonly stats = new SubagentStatistics();
private hooks?: SubagentHooks; private hooks?: SubagentHooks;
private readonly subagentId: string; private readonly subagentId: string;
@@ -312,14 +308,18 @@ export class SubAgentScope {
const chat = await this.createChatObject(context); const chat = await this.createChatObject(context);
if (!chat) { if (!chat) {
this.output.terminate_reason = SubagentTerminateMode.ERROR; this.terminateMode = SubagentTerminateMode.ERROR;
return; return;
} }
const abortController = new AbortController(); const abortController = new AbortController();
const onAbort = () => abortController.abort(); const onAbort = () => abortController.abort();
if (externalSignal) { if (externalSignal) {
if (externalSignal.aborted) abortController.abort(); if (externalSignal.aborted) {
abortController.abort();
this.terminateMode = SubagentTerminateMode.CANCELLED;
return;
}
externalSignal.addEventListener('abort', onAbort, { once: true }); externalSignal.addEventListener('abort', onAbort, { once: true });
} }
const toolRegistry = this.runtimeContext.getToolRegistry(); const toolRegistry = this.runtimeContext.getToolRegistry();
@@ -381,7 +381,7 @@ export class SubAgentScope {
this.runConfig.max_turns && this.runConfig.max_turns &&
turnCounter >= this.runConfig.max_turns turnCounter >= this.runConfig.max_turns
) { ) {
this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS; this.terminateMode = SubagentTerminateMode.MAX_TURNS;
break; break;
} }
let durationMin = (Date.now() - startTime) / (1000 * 60); let durationMin = (Date.now() - startTime) / (1000 * 60);
@@ -389,7 +389,7 @@ export class SubAgentScope {
this.runConfig.max_time_minutes && this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes durationMin >= this.runConfig.max_time_minutes
) { ) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; this.terminateMode = SubagentTerminateMode.TIMEOUT;
break; break;
} }
@@ -418,7 +418,10 @@ export class SubAgentScope {
let lastUsage: GenerateContentResponseUsageMetadata | undefined = let lastUsage: GenerateContentResponseUsageMetadata | undefined =
undefined; undefined;
for await (const resp of responseStream) { 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); if (resp.functionCalls) functionCalls.push(...resp.functionCalls);
const content = resp.candidates?.[0]?.content; const content = resp.candidates?.[0]?.content;
const parts = content?.parts || []; const parts = content?.parts || [];
@@ -443,7 +446,7 @@ export class SubAgentScope {
this.runConfig.max_time_minutes && this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes durationMin >= this.runConfig.max_time_minutes
) { ) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; this.terminateMode = SubagentTerminateMode.TIMEOUT;
break; break;
} }
@@ -483,8 +486,7 @@ export class SubAgentScope {
// No tool calls — treat this as the model's final answer. // No tool calls — treat this as the model's final answer.
if (roundText && roundText.trim().length > 0) { if (roundText && roundText.trim().length > 0) {
this.finalText = roundText.trim(); this.finalText = roundText.trim();
this.output.result = this.finalText; this.terminateMode = SubagentTerminateMode.GOAL;
this.output.terminate_reason = SubagentTerminateMode.GOAL;
break; break;
} }
// Otherwise, nudge the model to finalize a result. // Otherwise, nudge the model to finalize a result.
@@ -508,7 +510,7 @@ export class SubAgentScope {
} }
} catch (error) { } catch (error) {
console.error('Error during subagent execution:', error); console.error('Error during subagent execution:', error);
this.output.terminate_reason = SubagentTerminateMode.ERROR; this.terminateMode = SubagentTerminateMode.ERROR;
this.eventEmitter?.emit(SubAgentEventType.ERROR, { this.eventEmitter?.emit(SubAgentEventType.ERROR, {
subagentId: this.subagentId, subagentId: this.subagentId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -529,7 +531,7 @@ export class SubAgentScope {
const summary = this.stats.getSummary(Date.now()); const summary = this.stats.getSummary(Date.now());
this.eventEmitter?.emit(SubAgentEventType.FINISH, { this.eventEmitter?.emit(SubAgentEventType.FINISH, {
subagentId: this.subagentId, subagentId: this.subagentId,
terminate_reason: this.output.terminate_reason, terminate_reason: this.terminateMode,
timestamp: Date.now(), timestamp: Date.now(),
rounds: summary.rounds, rounds: summary.rounds,
totalDurationMs: summary.totalDurationMs, totalDurationMs: summary.totalDurationMs,
@@ -541,14 +543,13 @@ export class SubAgentScope {
totalTokens: summary.totalTokens, totalTokens: summary.totalTokens,
} as SubAgentFinishEvent); } as SubAgentFinishEvent);
// Log telemetry for subagent completion
const completionEvent = new SubagentExecutionEvent( const completionEvent = new SubagentExecutionEvent(
this.name, this.name,
this.output.terminate_reason === SubagentTerminateMode.GOAL this.terminateMode === SubagentTerminateMode.GOAL
? 'completed' ? 'completed'
: 'failed', : 'failed',
{ {
terminate_reason: this.output.terminate_reason, terminate_reason: this.terminateMode,
result: this.finalText, result: this.finalText,
execution_summary: this.stats.formatCompact( execution_summary: this.stats.formatCompact(
'Subagent execution completed', 'Subagent execution completed',
@@ -560,7 +561,7 @@ export class SubAgentScope {
await this.hooks?.onStop?.({ await this.hooks?.onStop?.({
subagentId: this.subagentId, subagentId: this.subagentId,
name: this.name, name: this.name,
terminateReason: this.output.terminate_reason, terminateReason: this.terminateMode,
summary: summary as unknown as Record<string, unknown>, summary: summary as unknown as Record<string, unknown>,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@@ -751,6 +752,10 @@ export class SubAgentScope {
return this.finalText; return this.finalText;
} }
getTerminateMode(): SubagentTerminateMode {
return this.terminateMode;
}
private async createChatObject(context: ContextState) { private async createChatObject(context: ContextState) {
if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) {
throw new Error( throw new Error(

View File

@@ -183,24 +183,10 @@ export enum SubagentTerminateMode {
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns. * Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/ */
MAX_TURNS = 'MAX_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. * Indicates that the subagent's execution was cancelled via an abort signal.
* This contains the direct output from the model's final response.
*/ */
result: string; CANCELLED = 'CANCELLED',
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
} }
/** /**

View File

@@ -294,7 +294,7 @@ export function recordContentRetryFailure(config: Config): void {
export function recordSubagentExecutionMetrics( export function recordSubagentExecutionMetrics(
config: Config, config: Config,
subagentName: string, subagentName: string,
status: 'started' | 'progress' | 'completed' | 'failed', status: 'started' | 'completed' | 'failed' | 'cancelled',
terminateReason?: string, terminateReason?: string,
): void { ): void {
if (!subagentExecutionCounter || !isMetricsInitialized) return; if (!subagentExecutionCounter || !isMetricsInitialized) return;

View File

@@ -448,14 +448,14 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
'event.name': 'subagent_execution'; 'event.name': 'subagent_execution';
'event.timestamp': string; 'event.timestamp': string;
subagent_name: string; subagent_name: string;
status: 'started' | 'progress' | 'completed' | 'failed'; status: 'started' | 'completed' | 'failed' | 'cancelled';
terminate_reason?: string; terminate_reason?: string;
result?: string; result?: string;
execution_summary?: string; execution_summary?: string;
constructor( constructor(
subagent_name: string, subagent_name: string,
status: 'started' | 'progress' | 'completed' | 'failed', status: 'started' | 'completed' | 'failed' | 'cancelled',
options?: { options?: {
terminate_reason?: string; terminate_reason?: string;
result?: string; result?: string;

View File

@@ -258,10 +258,8 @@ describe('TaskTool', () => {
beforeEach(() => { beforeEach(() => {
mockSubagentScope = { mockSubagentScope = {
runNonInteractive: vi.fn().mockResolvedValue(undefined), runNonInteractive: vi.fn().mockResolvedValue(undefined),
output: { result: 'Task completed successfully',
result: 'Task completed successfully', terminateMode: SubagentTerminateMode.GOAL,
terminate_reason: SubagentTerminateMode.GOAL,
},
getFinalText: vi.fn().mockReturnValue('Task completed successfully'), getFinalText: vi.fn().mockReturnValue('Task completed successfully'),
formatCompactResult: vi formatCompactResult: vi
.fn() .fn()
@@ -305,6 +303,7 @@ describe('TaskTool', () => {
successfulToolCalls: 3, successfulToolCalls: 3,
failedToolCalls: 0, failedToolCalls: 0,
}), }),
getTerminateMode: vi.fn().mockReturnValue(SubagentTerminateMode.GOAL),
} as unknown as SubAgentScope; } as unknown as SubAgentScope;
mockContextState = { mockContextState = {
@@ -375,25 +374,6 @@ describe('TaskTool', () => {
expect(display.subagentName).toBe('non-existent'); 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 () => { it('should handle execution errors gracefully', async () => {
vi.mocked(mockSubagentManager.createSubagentScope).mockRejectedValue( vi.mocked(mockSubagentManager.createSubagentScope).mockRejectedValue(
new Error('Creation failed'), new Error('Creation failed'),

View File

@@ -14,7 +14,7 @@ import {
} from './tools.js'; } from './tools.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.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 { ContextState } from '../subagents/subagent.js';
import { import {
SubAgentEventEmitter, SubAgentEventEmitter,
@@ -409,21 +409,6 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
// Set up event listeners for real-time updates // Set up event listeners for real-time updates
this.setupEventListeners(updateOutput); 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 // Send initial display
if (updateOutput) { if (updateOutput) {
updateOutput(this.currentDisplay); updateOutput(this.currentDisplay);
@@ -474,20 +459,31 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
// Get the results // Get the results
const finalText = subagentScope.getFinalText(); const finalText = subagentScope.getFinalText();
const terminateReason = subagentScope.output.terminate_reason; const terminateReason = subagentScope.getTerminateMode();
const success = terminateReason === 'GOAL'; const success = terminateReason === SubagentTerminateMode.GOAL;
const executionSummary = subagentScope.getExecutionSummary(); const executionSummary = subagentScope.getExecutionSummary();
// Update the final display state if (signal?.aborted) {
this.updateDisplay( this.updateDisplay(
{ {
status: success ? 'completed' : 'failed', status: 'cancelled',
terminateReason, terminateReason: 'CANCELLED',
result: finalText, result: finalText || 'Task was cancelled by user',
executionSummary, executionSummary,
}, },
updateOutput, updateOutput,
); );
} else {
this.updateDisplay(
{
status: success ? 'completed' : 'failed',
terminateReason,
result: finalText,
executionSummary,
},
updateOutput,
);
}
return { return {
llmContent: [{ text: finalText }], llmContent: [{ text: finalText }],
@@ -500,7 +496,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
const errorDisplay: TaskResultDisplay = { const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!, ...this.currentDisplay!,
status: 'failed' as const, status: 'failed',
terminateReason: 'ERROR', terminateReason: 'ERROR',
result: `Failed to run subagent: ${errorMessage}`, result: `Failed to run subagent: ${errorMessage}`,
}; };

View File

@@ -428,7 +428,7 @@ export interface TaskResultDisplay {
subagentColor?: string; subagentColor?: string;
taskDescription: string; taskDescription: string;
taskPrompt: string; taskPrompt: string;
status: 'running' | 'completed' | 'failed'; status: 'running' | 'completed' | 'failed' | 'cancelled';
terminateReason?: string; terminateReason?: string;
result?: string; result?: string;
executionSummary?: SubagentStatsSummary; executionSummary?: SubagentStatsSummary;