feat: add MCP tool progress update support in TUI and SDK mode

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-08 15:48:13 +08:00
parent 36931e1eab
commit 5ebbceea65
14 changed files with 724 additions and 25 deletions

View file

@ -1086,6 +1086,26 @@ describe('BaseJsonOutputAdapter', () => {
});
});
describe('emitToolProgress', () => {
it('should be a no-op in base class (does not emit any message)', () => {
const request: ToolCallRequestInfo = {
callId: 'tool-call-1',
name: 'mcp__echo-test__echo',
args: {},
isClientInitiated: false,
prompt_id: '',
};
adapter.emitToolProgress(request, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(adapter.emittedMessages).toHaveLength(0);
});
});
describe('buildResultMessage', () => {
beforeEach(() => {
adapter.startAssistantMessage();

View file

@ -12,6 +12,7 @@ import type {
SessionMetrics,
ServerGeminiStreamEvent,
TaskResultDisplay,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType,
@ -82,6 +83,18 @@ export interface MessageEmitter {
parentToolUseId?: string | null,
): void;
emitSystemMessage(subtype: string, data?: unknown): void;
/**
* Emits a tool progress stream event.
* Only emits when the adapter supports partial messages (stream mode).
* In non-streaming mode, this is a no-op.
*
* @param request - Tool call request info
* @param progress - Structured MCP progress data
*/
emitToolProgress(
request: ToolCallRequestInfo,
progress: McpToolProgressData,
): void;
}
/**
@ -1051,6 +1064,22 @@ export abstract class BaseJsonOutputAdapter {
this.emitMessageImpl(systemMessage);
}
/**
* Emits a tool progress stream event.
* Default implementation is a no-op. StreamJsonOutputAdapter overrides this
* to emit stream events when includePartialMessages is enabled.
*
* @param _request - Tool call request info
* @param _progress - Structured MCP progress data
*/
emitToolProgress(
_request: ToolCallRequestInfo,
_progress: McpToolProgressData,
): void {
// No-op in base class. Only StreamJsonOutputAdapter emits tool progress
// as stream events when includePartialMessages is enabled.
}
/**
* Builds a result message from options.
* Helper method used by both emitResult implementations.

View file

@ -882,6 +882,115 @@ describe('StreamJsonOutputAdapter', () => {
});
});
describe('emitToolProgress', () => {
const mockRequest = {
callId: 'tool-call-1',
name: 'mcp__echo-test__echo',
args: {},
isClientInitiated: false,
prompt_id: '',
};
it('should emit tool_progress stream event when includePartialMessages is true', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(stdoutWriteSpy).toHaveBeenCalledTimes(1);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('stream_event');
expect(parsed.parent_tool_use_id).toBeNull();
expect(parsed.session_id).toBe('test-session-id');
expect(parsed.uuid).toBeDefined();
expect(parsed.event).toEqual({
type: 'tool_progress',
tool_use_id: 'tool-call-1',
content: {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
},
});
});
it('should not emit tool_progress when includePartialMessages is false', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(stdoutWriteSpy).not.toHaveBeenCalled();
});
it('should emit multiple tool_progress events for sequential progress updates', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 3,
message: 'Echo: 1',
});
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 2,
total: 3,
message: 'Echo: 1, 2',
});
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 3,
total: 3,
message: 'Echo: 1, 2, 3',
});
expect(stdoutWriteSpy).toHaveBeenCalledTimes(3);
const events = stdoutWriteSpy.mock.calls.map(
(call: unknown[]) => JSON.parse(call[0] as string).event,
);
expect(events[0].content).toEqual({
type: 'mcp_tool_progress',
progress: 1,
total: 3,
message: 'Echo: 1',
});
expect(events[1].content).toEqual({
type: 'mcp_tool_progress',
progress: 2,
total: 3,
message: 'Echo: 1, 2',
});
expect(events[2].content).toEqual({
type: 'mcp_tool_progress',
progress: 3,
total: 3,
message: 'Echo: 1, 2, 3',
});
// All events should share the same tool_use_id
for (const event of events) {
expect(event.type).toBe('tool_progress');
expect(event.tool_use_id).toBe('tool-call-1');
}
});
});
describe('getSessionId and getModel', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);

View file

@ -5,7 +5,11 @@
*/
import { randomUUID } from 'node:crypto';
import type { Config } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallRequestInfo,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import type {
CLIAssistantMessage,
CLIMessage,
@ -267,6 +271,32 @@ export class StreamJsonOutputAdapter
}
}
/**
* Emits a tool progress stream event when partial messages are enabled.
* This overrides the no-op in BaseJsonOutputAdapter.
*/
override emitToolProgress(
request: ToolCallRequestInfo,
progress: McpToolProgressData,
): void {
if (!this.includePartialMessages) {
return;
}
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
event: {
type: 'tool_progress',
tool_use_id: request.callId,
content: progress,
},
};
this.emitMessageImpl(partial);
}
/**
* Emits stream events when partial messages are enabled.
* This is a private method specific to StreamJsonOutputAdapter.