mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
feat(vscode-ide-companion): add agent execution tool display (#2590)
Preserve structured agent rawOutput through the VSCode session pipeline. Render dedicated agent execution cards from shared webui components.
This commit is contained in:
parent
4ee9ca912c
commit
cd1be1c524
14 changed files with 667 additions and 7 deletions
106
docs/plans/2026-03-22-agent-tool-display-design.md
Normal file
106
docs/plans/2026-03-22-agent-tool-display-design.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Agent Tool Display Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a dedicated VSCode/web UI display for Agent tool executions so subagent progress, summaries, and failures render from structured `rawOutput` instead of falling back to the generic tool card.
|
||||
|
||||
**Architecture:** Preserve ACP `rawOutput` through the VSCode session/update pipeline into `ToolCallData`, then let the shared web UI router detect `task_execution` payloads and render a dedicated `AgentToolCall` component. Keep the change shared in `packages/webui` so VSCode and `ChatViewer` stay aligned.
|
||||
|
||||
**Tech Stack:** TypeScript, React, Vitest, shared `@qwen-code/webui` tool-call components.
|
||||
|
||||
### Task 1: Lock in the failing data-flow behavior
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts`
|
||||
- Create: `packages/vscode-ide-companion/src/webview/hooks/useToolCalls.test.tsx`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
- Add a session handler test asserting `tool_call_update` forwards `rawOutput` when ACP sends a `task_execution` payload.
|
||||
- Add a hook test asserting `useToolCalls` stores and updates `rawOutput` for an agent tool call.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx`
|
||||
|
||||
Expected: failures because `rawOutput` is not preserved in the current handler/hook pipeline.
|
||||
|
||||
### Task 2: Lock in the failing renderer behavior
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Render the routed tool call with `kind: 'other'` plus `rawOutput.type === 'task_execution'`.
|
||||
- Assert the task description, active child tool, summary, and failure reason render from a dedicated agent display instead of generic text output.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test --workspace=packages/vscode-ide-companion -- --run packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx`
|
||||
|
||||
Expected: failure because the router only keys off `kind` and no dedicated agent component exists.
|
||||
|
||||
### Task 3: Preserve structured agent output end-to-end
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/vscode-ide-companion/src/types/chatTypes.ts`
|
||||
- Modify: `packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts`
|
||||
- Modify: `packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts`
|
||||
- Modify: `packages/webui/src/components/toolcalls/shared/types.ts`
|
||||
|
||||
**Step 1: Implement the minimal data model changes**
|
||||
|
||||
- Add optional `rawOutput` to the VSCode session/webview tool-call types.
|
||||
- Forward `rawOutput` in `QwenSessionUpdateHandler`.
|
||||
- Store/merge `rawOutput` in `useToolCalls`.
|
||||
- Expose `rawOutput` in shared web UI tool-call data types.
|
||||
|
||||
**Step 2: Run the focused tests**
|
||||
|
||||
Run: `npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx`
|
||||
|
||||
Expected: pass.
|
||||
|
||||
### Task 4: Add the shared agent tool-call UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/webui/src/components/toolcalls/AgentToolCall.tsx`
|
||||
- Modify: `packages/webui/src/components/toolcalls/index.ts`
|
||||
- Modify: `packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx`
|
||||
- Modify: `packages/webui/src/components/ChatViewer/ChatViewer.tsx`
|
||||
|
||||
**Step 1: Implement the minimal renderer**
|
||||
|
||||
- Add a guard for `rawOutput.type === 'task_execution'`.
|
||||
- Render task description as the header.
|
||||
- Show agent name + status, currently running child tools, completion summary, and failure/cancel reason.
|
||||
- Keep the layout compatible with multiple parallel agent cards by rendering each tool call independently.
|
||||
|
||||
**Step 2: Run the focused renderer test**
|
||||
|
||||
Run: `npm test --workspace=packages/vscode-ide-companion -- --run packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx`
|
||||
|
||||
Expected: pass.
|
||||
|
||||
### Task 5: Verify the integrated surface
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/webui/src/index.ts`
|
||||
|
||||
**Step 1: Export the new shared component if needed**
|
||||
|
||||
- Re-export any new component/types needed by VSCode or `ChatViewer`.
|
||||
|
||||
**Step 2: Run package verification**
|
||||
|
||||
Run: `npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx`
|
||||
Run: `npm run check-types --workspace=packages/vscode-ide-companion`
|
||||
Run: `npm run typecheck --workspace=packages/webui`
|
||||
|
||||
Expected: all targeted tests and typechecks pass.
|
||||
|
|
@ -169,6 +169,48 @@ describe('QwenSessionUpdateHandler', () => {
|
|||
locations: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards rawOutput for structured agent execution updates', () => {
|
||||
const rawOutput = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'Explore',
|
||||
taskDescription: 'Explore auth logic',
|
||||
taskPrompt: 'Inspect auth flow implementation',
|
||||
status: 'running',
|
||||
toolCalls: [
|
||||
{
|
||||
callId: 'child-1',
|
||||
name: 'read',
|
||||
status: 'executing',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const toolCallUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-agent',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'in_progress',
|
||||
rawOutput,
|
||||
},
|
||||
} as SessionNotification;
|
||||
|
||||
handler.handleSessionUpdate(toolCallUpdate);
|
||||
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith({
|
||||
toolCallId: 'call-agent',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'in_progress',
|
||||
rawInput: undefined,
|
||||
rawOutput,
|
||||
content: undefined,
|
||||
locations: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan handling', () => {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export class QwenSessionUpdateHandler {
|
|||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
rawOutput: (update as { rawOutput?: unknown }).rawOutput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
|
|
@ -134,6 +135,7 @@ export class QwenSessionUpdateHandler {
|
|||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
rawOutput: (update as { rawOutput?: unknown }).rawOutput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export interface ToolCallUpdateData {
|
|||
title?: string;
|
||||
status?: string;
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
locations?: Array<{ path: string; line?: number | null }>;
|
||||
timestamp?: number;
|
||||
|
|
@ -88,6 +89,7 @@ export interface ToolCallUpdate {
|
|||
title?: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ToolCallRouter } from './index.js';
|
||||
|
||||
vi.mock('@qwen-code/webui', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
const renderLabel = (label: string) =>
|
||||
function MockTool({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: {
|
||||
title?: string;
|
||||
rawOutput?: {
|
||||
taskDescription?: string;
|
||||
terminateReason?: string;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
undefined,
|
||||
`${label}:${toolCall.rawOutput?.taskDescription || toolCall.title || ''}:${toolCall.rawOutput?.terminateReason || ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
shouldShowToolCall: () => true,
|
||||
isAgentExecutionToolCall: (toolCall: { rawOutput?: { type?: string } }) =>
|
||||
toolCall.rawOutput?.type === 'task_execution',
|
||||
GenericToolCall: renderLabel('generic'),
|
||||
ThinkToolCall: renderLabel('think'),
|
||||
SaveMemoryToolCall: renderLabel('memory'),
|
||||
EditToolCall: renderLabel('edit'),
|
||||
WriteToolCall: renderLabel('write'),
|
||||
SearchToolCall: renderLabel('search'),
|
||||
UpdatedPlanToolCall: renderLabel('plan'),
|
||||
ShellToolCall: renderLabel('shell'),
|
||||
ReadToolCall: renderLabel('read'),
|
||||
WebFetchToolCall: renderLabel('web'),
|
||||
AgentToolCall: renderLabel('agent'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ToolCallRouter agent execution rendering', () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a dedicated view for structured agent progress and summary', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<ToolCallRouter
|
||||
toolCall={
|
||||
{
|
||||
toolCallId: 'agent-1',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'completed',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
subagentName: 'Explore',
|
||||
taskDescription: 'Explore auth logic',
|
||||
taskPrompt: 'Inspect auth flow implementation',
|
||||
status: 'completed',
|
||||
toolCalls: [
|
||||
{
|
||||
callId: 'child-1',
|
||||
name: 'read',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
callId: 'child-2',
|
||||
name: 'grep',
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
executionSummary: {
|
||||
totalToolCalls: 2,
|
||||
totalTokens: 1234,
|
||||
totalDurationMs: 2200,
|
||||
},
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain('agent:Explore auth logic');
|
||||
});
|
||||
|
||||
it('renders the agent failure reason from structured rawOutput', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<ToolCallRouter
|
||||
toolCall={
|
||||
{
|
||||
toolCallId: 'agent-2',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'failed',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
subagentName: 'Explore',
|
||||
taskDescription: 'Explore auth logic',
|
||||
taskPrompt: 'Inspect auth flow implementation',
|
||||
status: 'failed',
|
||||
terminateReason: 'Subagent crashed',
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain(
|
||||
'agent:Explore auth logic:Subagent crashed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,8 @@ import type { FC } from 'react';
|
|||
import {
|
||||
shouldShowToolCall,
|
||||
// All ToolCall components from webui
|
||||
AgentToolCall,
|
||||
isAgentExecutionToolCall,
|
||||
GenericToolCall,
|
||||
ThinkToolCall,
|
||||
EditToolCall,
|
||||
|
|
@ -21,13 +23,19 @@ import {
|
|||
ReadToolCall,
|
||||
WebFetchToolCall,
|
||||
} from '@qwen-code/webui';
|
||||
import type { BaseToolCallProps } from '@qwen-code/webui';
|
||||
import type { BaseToolCallProps, ToolCallData } from '@qwen-code/webui';
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate tool call component based on kind
|
||||
*/
|
||||
export const getToolCallComponent = (kind: string): FC<BaseToolCallProps> => {
|
||||
const normalizedKind = kind.toLowerCase();
|
||||
export const getToolCallComponent = (
|
||||
toolCall: ToolCallData,
|
||||
): FC<BaseToolCallProps> => {
|
||||
if (isAgentExecutionToolCall(toolCall)) {
|
||||
return AgentToolCall;
|
||||
}
|
||||
|
||||
const normalizedKind = toolCall.kind.toLowerCase();
|
||||
|
||||
// Route to specialized components
|
||||
switch (normalizedKind) {
|
||||
|
|
@ -90,7 +98,7 @@ export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
}
|
||||
|
||||
// Get the appropriate component for this kind
|
||||
const Component = getToolCallComponent(toolCall.kind);
|
||||
const Component = getToolCallComponent(toolCall);
|
||||
|
||||
// Render the specialized component
|
||||
return <Component toolCall={toolCall} />;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useToolCalls } from './useToolCalls.js';
|
||||
import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
|
||||
type HookSnapshot = ReturnType<typeof useToolCalls>;
|
||||
|
||||
let latestSnapshot: HookSnapshot | null = null;
|
||||
|
||||
function HookHarness() {
|
||||
latestSnapshot = useToolCalls();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useToolCalls', () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(<HookHarness />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
latestSnapshot = null;
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('stores structured rawOutput for agent tool calls across updates', () => {
|
||||
const startUpdate = {
|
||||
type: 'tool_call',
|
||||
toolCallId: 'agent-1',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'in_progress',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
subagentName: 'Explore',
|
||||
taskDescription: 'Explore auth logic',
|
||||
taskPrompt: 'Inspect auth flow implementation',
|
||||
status: 'running',
|
||||
},
|
||||
} as ToolCallUpdate & { rawOutput: unknown };
|
||||
|
||||
act(() => {
|
||||
latestSnapshot?.handleToolCallUpdate(startUpdate);
|
||||
});
|
||||
|
||||
expect(latestSnapshot?.toolCalls.get('agent-1')).toMatchObject({
|
||||
toolCallId: 'agent-1',
|
||||
kind: 'other',
|
||||
title: 'Launch agent',
|
||||
status: 'in_progress',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
taskDescription: 'Explore auth logic',
|
||||
},
|
||||
});
|
||||
|
||||
const completionUpdate = {
|
||||
type: 'tool_call_update',
|
||||
toolCallId: 'agent-1',
|
||||
status: 'completed',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
subagentName: 'Explore',
|
||||
taskDescription: 'Explore auth logic',
|
||||
taskPrompt: 'Inspect auth flow implementation',
|
||||
status: 'completed',
|
||||
executionSummary: {
|
||||
totalToolCalls: 3,
|
||||
totalTokens: 1234,
|
||||
totalDurationMs: 2200,
|
||||
},
|
||||
},
|
||||
} as ToolCallUpdate & { rawOutput: unknown };
|
||||
|
||||
act(() => {
|
||||
latestSnapshot?.handleToolCallUpdate(completionUpdate);
|
||||
});
|
||||
|
||||
expect(latestSnapshot?.toolCalls.get('agent-1')).toMatchObject({
|
||||
status: 'completed',
|
||||
rawOutput: {
|
||||
type: 'task_execution',
|
||||
status: 'completed',
|
||||
executionSummary: {
|
||||
totalToolCalls: 3,
|
||||
totalTokens: 1234,
|
||||
totalDurationMs: 2200,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -180,6 +180,7 @@ export const useToolCalls = () => {
|
|||
title: safeTitle(update.title),
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
rawOutput: update.rawOutput,
|
||||
content,
|
||||
locations: update.locations,
|
||||
timestamp: resolveTimestamp(update),
|
||||
|
|
@ -216,6 +217,9 @@ export const useToolCalls = () => {
|
|||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
...(update.rawOutput !== undefined && {
|
||||
rawOutput: update.rawOutput,
|
||||
}),
|
||||
content: mergedContent,
|
||||
...(update.locations && { locations: update.locations }),
|
||||
timestamp: nextTimestamp,
|
||||
|
|
@ -227,6 +231,7 @@ export const useToolCalls = () => {
|
|||
title: update.title ? safeTitle(update.title) : '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
rawOutput: update.rawOutput,
|
||||
content: updatedContent,
|
||||
locations: update.locations,
|
||||
timestamp: resolveTimestamp(update),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { UserMessage } from '../messages/UserMessage.js';
|
|||
import { AssistantMessage } from '../messages/Assistant/AssistantMessage.js';
|
||||
import { ThinkingMessage } from '../messages/ThinkingMessage.js';
|
||||
import {
|
||||
AgentToolCall,
|
||||
GenericToolCall,
|
||||
ThinkToolCall,
|
||||
EditToolCall,
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
ReadToolCall,
|
||||
WebFetchToolCall,
|
||||
shouldShowToolCall,
|
||||
isAgentExecutionToolCall,
|
||||
} from '../toolcalls/index.js';
|
||||
import type { ToolCallData as BaseToolCallData } from '../toolcalls/index.js';
|
||||
import './ChatViewer.css';
|
||||
|
|
@ -145,8 +147,12 @@ function parseTimestamp(isoString: string): number {
|
|||
/**
|
||||
* Get the appropriate tool call component based on kind
|
||||
*/
|
||||
function getToolCallComponent(kind: string) {
|
||||
const normalizedKind = kind.toLowerCase();
|
||||
function getToolCallComponent(toolCall: BaseToolCallData) {
|
||||
if (isAgentExecutionToolCall(toolCall)) {
|
||||
return AgentToolCall;
|
||||
}
|
||||
|
||||
const normalizedKind = toolCall.kind.toLowerCase();
|
||||
|
||||
switch (normalizedKind) {
|
||||
case 'read':
|
||||
|
|
@ -313,7 +319,7 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
|
|||
|
||||
// Handle tool calls
|
||||
if (msg.type === 'tool_call' && msg.toolCall) {
|
||||
const ToolCallComponent = getToolCallComponent(msg.toolCall.kind);
|
||||
const ToolCallComponent = getToolCallComponent(msg.toolCall);
|
||||
|
||||
if (!ToolCallComponent) {
|
||||
return null;
|
||||
|
|
|
|||
156
packages/webui/src/components/toolcalls/AgentToolCall.tsx
Normal file
156
packages/webui/src/components/toolcalls/AgentToolCall.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Dedicated agent tool call component for structured subagent execution output.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { ToolCallCard, ToolCallRow, safeTitle } from './shared/index.js';
|
||||
import type {
|
||||
AgentExecutionRawOutput,
|
||||
BaseToolCallProps,
|
||||
ToolCallData,
|
||||
} from './shared/index.js';
|
||||
|
||||
const MAX_VISIBLE_TOOL_CALLS = 5;
|
||||
|
||||
export const isAgentExecutionRawOutput = (
|
||||
value: unknown,
|
||||
): value is AgentExecutionRawOutput =>
|
||||
Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
(value as { type?: unknown }).type === 'task_execution' &&
|
||||
'taskDescription' in value &&
|
||||
'status' in value,
|
||||
);
|
||||
|
||||
export const isAgentExecutionToolCall = (
|
||||
toolCall: ToolCallData,
|
||||
): toolCall is ToolCallData & { rawOutput: AgentExecutionRawOutput } =>
|
||||
isAgentExecutionRawOutput(toolCall.rawOutput);
|
||||
|
||||
const STATUS_LABELS: Record<AgentExecutionRawOutput['status'], string> = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
const CHILD_STATUS_LABELS: Record<
|
||||
NonNullable<AgentExecutionRawOutput['toolCalls']>[number]['status'],
|
||||
string
|
||||
> = {
|
||||
executing: 'Running',
|
||||
awaiting_approval: 'Awaiting approval',
|
||||
success: 'Completed',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const formatDuration = (durationMs: number): string => {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`;
|
||||
}
|
||||
|
||||
if (durationMs < 60_000) {
|
||||
return `${(durationMs / 1000).toFixed(durationMs % 1000 === 0 ? 0 : 1)}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(durationMs / 60_000);
|
||||
const seconds = Math.round((durationMs % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
const getHeaderTitle = (
|
||||
data: AgentExecutionRawOutput,
|
||||
fallbackTitle: ToolCallData['title'],
|
||||
): string => data.taskDescription || safeTitle(fallbackTitle) || 'Agent Task';
|
||||
|
||||
export const AgentToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
if (!isAgentExecutionToolCall(toolCall)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = toolCall.rawOutput;
|
||||
const visibleToolCalls = data.toolCalls?.slice(-MAX_VISIBLE_TOOL_CALLS) ?? [];
|
||||
const hiddenToolCallCount = Math.max(
|
||||
0,
|
||||
(data.toolCalls?.length ?? 0) - visibleToolCalls.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="🤖">
|
||||
<ToolCallRow label="Agent">
|
||||
<div className="font-medium text-[var(--app-primary-foreground)]">
|
||||
{getHeaderTitle(data, toolCall.title)}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
|
||||
<ToolCallRow label="Status">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{data.subagentName}</span>
|
||||
<span className="text-[var(--app-secondary-foreground)]">
|
||||
{STATUS_LABELS[data.status]}
|
||||
</span>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
|
||||
{visibleToolCalls.length > 0 && (
|
||||
<ToolCallRow label={data.status === 'running' ? 'Progress' : 'Tools'}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{visibleToolCalls.map((childToolCall) => (
|
||||
<div
|
||||
key={childToolCall.callId}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span className="font-mono text-[12px] text-[var(--app-primary-foreground)]">
|
||||
{childToolCall.name}
|
||||
</span>
|
||||
<span className="text-[var(--app-secondary-foreground)]">
|
||||
{CHILD_STATUS_LABELS[childToolCall.status]}
|
||||
</span>
|
||||
{childToolCall.description && (
|
||||
<span className="text-[var(--app-secondary-foreground)]">
|
||||
{childToolCall.description}
|
||||
</span>
|
||||
)}
|
||||
{childToolCall.error && (
|
||||
<span className="text-[#c74e39]">{childToolCall.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hiddenToolCallCount > 0 && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">
|
||||
+{hiddenToolCallCount} more tool calls
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
)}
|
||||
|
||||
{data.executionSummary && (
|
||||
<ToolCallRow label="Summary">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>{data.executionSummary.totalToolCalls} tool calls</span>
|
||||
<span>
|
||||
{data.executionSummary.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
<span>{formatDuration(data.executionSummary.totalDurationMs)}</span>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
)}
|
||||
|
||||
{(data.status === 'failed' || data.status === 'cancelled') &&
|
||||
data.terminateReason && (
|
||||
<ToolCallRow label="Reason">
|
||||
<div className="text-[#c74e39] font-medium">
|
||||
{data.terminateReason}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
)}
|
||||
</ToolCallCard>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,11 @@ export * from './shared/index.js';
|
|||
// Business ToolCall components
|
||||
export { ThinkToolCall } from './ThinkToolCall.js';
|
||||
export { GenericToolCall } from './GenericToolCall.js';
|
||||
export {
|
||||
AgentToolCall,
|
||||
isAgentExecutionRawOutput,
|
||||
isAgentExecutionToolCall,
|
||||
} from './AgentToolCall.js';
|
||||
export { EditToolCall } from './EditToolCall.js';
|
||||
export { WriteToolCall } from './WriteToolCall.js';
|
||||
export { SearchToolCall } from './SearchToolCall.js';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export {
|
|||
|
||||
// Types
|
||||
export type {
|
||||
AgentExecutionRawOutput,
|
||||
AgentExecutionStatus,
|
||||
AgentExecutionSummary,
|
||||
AgentExecutionToolCall,
|
||||
AgentToolCallStatus,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolCallStatus,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,48 @@ export interface ToolCallLocation {
|
|||
*/
|
||||
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
export type AgentExecutionStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type AgentToolCallStatus =
|
||||
| 'executing'
|
||||
| 'awaiting_approval'
|
||||
| 'success'
|
||||
| 'failed';
|
||||
|
||||
export interface AgentExecutionSummary {
|
||||
totalToolCalls: number;
|
||||
totalTokens: number;
|
||||
totalDurationMs: number;
|
||||
successfulToolCalls?: number;
|
||||
failedToolCalls?: number;
|
||||
successRate?: number;
|
||||
}
|
||||
|
||||
export interface AgentExecutionToolCall {
|
||||
callId: string;
|
||||
name: string;
|
||||
status: AgentToolCallStatus;
|
||||
error?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AgentExecutionRawOutput {
|
||||
type: 'task_execution';
|
||||
subagentName: string;
|
||||
subagentColor?: string;
|
||||
taskDescription: string;
|
||||
taskPrompt: string;
|
||||
status: AgentExecutionStatus;
|
||||
terminateReason?: string;
|
||||
result?: string;
|
||||
executionSummary?: AgentExecutionSummary;
|
||||
toolCalls?: AgentExecutionToolCall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base tool call data interface
|
||||
*/
|
||||
|
|
@ -46,6 +88,7 @@ export interface ToolCallData {
|
|||
title: string | object;
|
||||
status: ToolCallStatus;
|
||||
rawInput?: string | object;
|
||||
rawOutput?: unknown;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
timestamp?: number;
|
||||
|
|
|
|||
|
|
@ -152,6 +152,9 @@ export {
|
|||
// Business ToolCall components
|
||||
ThinkToolCall,
|
||||
GenericToolCall,
|
||||
AgentToolCall,
|
||||
isAgentExecutionRawOutput,
|
||||
isAgentExecutionToolCall,
|
||||
EditToolCall,
|
||||
WriteToolCall,
|
||||
SearchToolCall,
|
||||
|
|
@ -163,6 +166,11 @@ export {
|
|||
} from './components/toolcalls';
|
||||
export type {
|
||||
ToolCallContainerProps,
|
||||
AgentExecutionRawOutput,
|
||||
AgentExecutionStatus,
|
||||
AgentExecutionSummary,
|
||||
AgentExecutionToolCall,
|
||||
AgentToolCallStatus,
|
||||
ToolCallContent,
|
||||
ToolCallData,
|
||||
BaseToolCallProps,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue