mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
663 lines
20 KiB
TypeScript
663 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ToolCallEmitter } from './ToolCallEmitter.js';
|
|
import type { SessionContext } from '../types.js';
|
|
import type {
|
|
Config,
|
|
ToolRegistry,
|
|
AnyDeclarativeTool,
|
|
AnyToolInvocation,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import { Kind, TodoWriteTool } from '@qwen-code/qwen-code-core';
|
|
import type { Part } from '@google/genai';
|
|
|
|
// Helper to create mock message parts for tests
|
|
const createMockMessage = (text?: string): Part[] =>
|
|
text
|
|
? [{ functionResponse: { name: 'test', response: { output: text } } }]
|
|
: [];
|
|
|
|
describe('ToolCallEmitter', () => {
|
|
let mockContext: SessionContext;
|
|
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
|
let mockToolRegistry: ToolRegistry;
|
|
let emitter: ToolCallEmitter;
|
|
|
|
// Helper to create mock tool
|
|
const createMockTool = (
|
|
overrides: Partial<AnyDeclarativeTool> = {},
|
|
): AnyDeclarativeTool =>
|
|
({
|
|
name: 'test_tool',
|
|
kind: Kind.Other,
|
|
build: vi.fn().mockReturnValue({
|
|
getDescription: () => 'Test tool description',
|
|
toolLocations: () => [{ path: '/test/file.ts', line: 10 }],
|
|
} as unknown as AnyToolInvocation),
|
|
...overrides,
|
|
}) as unknown as AnyDeclarativeTool;
|
|
|
|
beforeEach(() => {
|
|
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
|
mockToolRegistry = {
|
|
getTool: vi.fn().mockReturnValue(null),
|
|
} as unknown as ToolRegistry;
|
|
|
|
mockContext = {
|
|
sessionId: 'test-session-id',
|
|
config: {
|
|
getToolRegistry: () => mockToolRegistry,
|
|
} as unknown as Config,
|
|
sendUpdate: sendUpdateSpy,
|
|
};
|
|
|
|
emitter = new ToolCallEmitter(mockContext);
|
|
});
|
|
|
|
describe('emitStart', () => {
|
|
it('should emit tool_call update with basic params when tool not in registry', async () => {
|
|
const result = await emitter.emitStart({
|
|
toolName: 'unknown_tool',
|
|
callId: 'call-123',
|
|
args: { arg1: 'value1' },
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'call-123',
|
|
status: 'pending',
|
|
title: 'unknown_tool', // Falls back to tool name
|
|
content: [],
|
|
locations: [],
|
|
kind: 'other',
|
|
rawInput: { arg1: 'value1' },
|
|
});
|
|
});
|
|
|
|
it('should emit tool_call with resolved metadata when tool is in registry', async () => {
|
|
const mockTool = createMockTool({ kind: Kind.Edit });
|
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
|
|
|
const result = await emitter.emitStart({
|
|
toolName: 'edit_file',
|
|
callId: 'call-456',
|
|
args: { path: '/test.ts' },
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'call-456',
|
|
status: 'pending',
|
|
title: 'edit_file: Test tool description',
|
|
content: [],
|
|
locations: [{ path: '/test/file.ts', line: 10 }],
|
|
kind: 'edit',
|
|
rawInput: { path: '/test.ts' },
|
|
});
|
|
});
|
|
|
|
it('should skip emit for TodoWriteTool and return false', async () => {
|
|
const result = await emitter.emitStart({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo',
|
|
args: { todos: [] },
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle empty args', async () => {
|
|
await emitter.emitStart({
|
|
toolName: 'test_tool',
|
|
callId: 'call-empty',
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
rawInput: {},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should fall back gracefully when tool build fails', async () => {
|
|
const mockTool = createMockTool();
|
|
vi.mocked(mockTool.build).mockImplementation(() => {
|
|
throw new Error('Build failed');
|
|
});
|
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
|
|
|
await emitter.emitStart({
|
|
toolName: 'failing_tool',
|
|
callId: 'call-fail',
|
|
args: { invalid: true },
|
|
});
|
|
|
|
// Should use fallback values
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'call-fail',
|
|
status: 'pending',
|
|
title: 'failing_tool', // Fallback to tool name
|
|
content: [],
|
|
locations: [], // Fallback to empty
|
|
kind: 'other', // Fallback to other
|
|
rawInput: { invalid: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('emitResult', () => {
|
|
it('should emit tool_call_update with completed status on success', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-123',
|
|
success: true,
|
|
message: createMockMessage('Tool completed successfully'),
|
|
resultDisplay: 'Tool completed successfully',
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-123',
|
|
status: 'completed',
|
|
rawOutput: 'Tool completed successfully',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should emit tool_call_update with failed status on failure', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-123',
|
|
success: false,
|
|
message: [],
|
|
error: new Error('Something went wrong'),
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-123',
|
|
status: 'failed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: { type: 'text', text: 'Something went wrong' },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should handle diff display format', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'edit_file',
|
|
callId: 'call-edit',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: {
|
|
fileName: '/test/file.ts',
|
|
originalContent: 'old content',
|
|
newContent: 'new content',
|
|
},
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-edit',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'diff',
|
|
path: '/test/file.ts',
|
|
oldText: 'old content',
|
|
newText: 'new content',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should transform message parts to content', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-123',
|
|
success: true,
|
|
message: [{ text: 'Some text output' }],
|
|
resultDisplay: 'raw output',
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-123',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: { type: 'text', text: 'Some text output' },
|
|
},
|
|
],
|
|
rawOutput: 'raw output',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle empty message parts', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-empty',
|
|
success: true,
|
|
message: [],
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-empty',
|
|
status: 'completed',
|
|
content: [],
|
|
});
|
|
});
|
|
|
|
describe('TodoWriteTool handling', () => {
|
|
it('should emit plan update instead of tool_call_update for TodoWriteTool', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: {
|
|
type: 'todo_list',
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'in_progress' },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'plan',
|
|
entries: [
|
|
{ content: 'Task 1', priority: 'medium', status: 'pending' },
|
|
{ content: 'Task 2', priority: 'medium', status: 'in_progress' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should use args as fallback for TodoWriteTool todos', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: null,
|
|
args: {
|
|
todos: [{ id: '1', content: 'From args', status: 'completed' }],
|
|
},
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'plan',
|
|
entries: [
|
|
{ content: 'From args', priority: 'medium', status: 'completed' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should not emit anything for TodoWriteTool with empty todos', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: { type: 'todo_list', todos: [] },
|
|
});
|
|
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not emit anything for TodoWriteTool with no extractable todos', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: 'Some string result',
|
|
});
|
|
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('emitError', () => {
|
|
it('should emit tool_call_update with failed status and error message', async () => {
|
|
const error = new Error('Connection timeout');
|
|
|
|
await emitter.emitError('call-123', error);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-123',
|
|
status: 'failed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: { type: 'text', text: 'Connection timeout' },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isTodoWriteTool', () => {
|
|
it('should return true for TodoWriteTool.Name', () => {
|
|
expect(emitter.isTodoWriteTool(TodoWriteTool.Name)).toBe(true);
|
|
});
|
|
|
|
it('should return false for other tool names', () => {
|
|
expect(emitter.isTodoWriteTool('read_file')).toBe(false);
|
|
expect(emitter.isTodoWriteTool('edit_file')).toBe(false);
|
|
expect(emitter.isTodoWriteTool('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('mapToolKind', () => {
|
|
it('should map all Kind values correctly', () => {
|
|
expect(emitter.mapToolKind(Kind.Read)).toBe('read');
|
|
expect(emitter.mapToolKind(Kind.Edit)).toBe('edit');
|
|
expect(emitter.mapToolKind(Kind.Delete)).toBe('delete');
|
|
expect(emitter.mapToolKind(Kind.Move)).toBe('move');
|
|
expect(emitter.mapToolKind(Kind.Search)).toBe('search');
|
|
expect(emitter.mapToolKind(Kind.Execute)).toBe('execute');
|
|
expect(emitter.mapToolKind(Kind.Think)).toBe('think');
|
|
expect(emitter.mapToolKind(Kind.Fetch)).toBe('fetch');
|
|
expect(emitter.mapToolKind(Kind.Other)).toBe('other');
|
|
});
|
|
|
|
it('should map exit_plan_mode tool to switch_mode kind', () => {
|
|
// exit_plan_mode uses Kind.Think internally, but should map to switch_mode per ACP spec
|
|
expect(emitter.mapToolKind(Kind.Think, 'exit_plan_mode')).toBe(
|
|
'switch_mode',
|
|
);
|
|
});
|
|
|
|
it('should not affect other tools with Kind.Think', () => {
|
|
// Other tools with Kind.Think should still map to think
|
|
expect(emitter.mapToolKind(Kind.Think, 'todo_write')).toBe('think');
|
|
expect(emitter.mapToolKind(Kind.Think, 'some_other_tool')).toBe('think');
|
|
});
|
|
});
|
|
|
|
describe('isExitPlanModeTool', () => {
|
|
it('should return true for exit_plan_mode tool name', () => {
|
|
expect(emitter.isExitPlanModeTool('exit_plan_mode')).toBe(true);
|
|
});
|
|
|
|
it('should return false for other tool names', () => {
|
|
expect(emitter.isExitPlanModeTool('read_file')).toBe(false);
|
|
expect(emitter.isExitPlanModeTool('edit_file')).toBe(false);
|
|
expect(emitter.isExitPlanModeTool('todo_write')).toBe(false);
|
|
expect(emitter.isExitPlanModeTool('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveToolMetadata', () => {
|
|
it('should return defaults when tool not found', () => {
|
|
const metadata = emitter.resolveToolMetadata('unknown_tool', {
|
|
arg: 'value',
|
|
});
|
|
|
|
expect(metadata).toEqual({
|
|
title: 'unknown_tool',
|
|
locations: [],
|
|
kind: 'other',
|
|
});
|
|
});
|
|
|
|
it('should return tool metadata when tool found and built successfully', () => {
|
|
const mockTool = createMockTool({ kind: Kind.Search });
|
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
|
|
|
const metadata = emitter.resolveToolMetadata('search_tool', {
|
|
query: 'test',
|
|
});
|
|
|
|
expect(metadata).toEqual({
|
|
title: 'search_tool: Test tool description',
|
|
locations: [{ path: '/test/file.ts', line: 10 }],
|
|
kind: 'search',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('integration: consistent behavior across flows', () => {
|
|
it('should handle the same params consistently regardless of source', async () => {
|
|
// This test verifies that the emitter produces consistent output
|
|
// whether called from normal flow, replay, or subagent
|
|
|
|
const params = {
|
|
toolName: 'read_file',
|
|
callId: 'consistent-call',
|
|
args: { path: '/test.ts' },
|
|
};
|
|
|
|
// First call (e.g., from normal flow)
|
|
await emitter.emitStart(params);
|
|
const firstCall = sendUpdateSpy.mock.calls[0][0];
|
|
|
|
// Reset and call again (e.g., from replay)
|
|
sendUpdateSpy.mockClear();
|
|
await emitter.emitStart(params);
|
|
const secondCall = sendUpdateSpy.mock.calls[0][0];
|
|
|
|
// Both should produce identical output
|
|
expect(firstCall).toEqual(secondCall);
|
|
});
|
|
});
|
|
|
|
describe('fixes verification', () => {
|
|
describe('Fix 2: functionResponse parts are stringified', () => {
|
|
it('should stringify functionResponse parts in message', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-func',
|
|
success: true,
|
|
message: [
|
|
{
|
|
functionResponse: {
|
|
name: 'test',
|
|
response: { output: 'test output' },
|
|
},
|
|
},
|
|
],
|
|
resultDisplay: { unknownField: 'value', nested: { data: 123 } },
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-func',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: {
|
|
type: 'text',
|
|
text: 'test output',
|
|
},
|
|
},
|
|
],
|
|
rawOutput: { unknownField: 'value', nested: { data: 123 } },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Fix 3: rawOutput is included in emitResult', () => {
|
|
it('should include rawOutput when resultDisplay is provided', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-extra',
|
|
success: true,
|
|
message: [{ text: 'Result text' }],
|
|
resultDisplay: 'Result text',
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-extra',
|
|
status: 'completed',
|
|
rawOutput: 'Result text',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not include rawOutput when resultDisplay is undefined', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-null',
|
|
success: true,
|
|
message: [],
|
|
});
|
|
|
|
const call = sendUpdateSpy.mock.calls[0][0];
|
|
expect(call.rawOutput).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Fix 5: Line null mapping in resolveToolMetadata', () => {
|
|
it('should map undefined line to null in locations', () => {
|
|
const mockTool = createMockTool();
|
|
// Override toolLocations to return undefined line
|
|
vi.mocked(mockTool.build).mockReturnValue({
|
|
getDescription: () => 'Description',
|
|
toolLocations: () => [
|
|
{ path: '/file1.ts', line: 10 },
|
|
{ path: '/file2.ts', line: undefined },
|
|
{ path: '/file3.ts' }, // no line property
|
|
],
|
|
} as unknown as AnyToolInvocation);
|
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
|
|
|
const metadata = emitter.resolveToolMetadata('test_tool', {
|
|
arg: 'value',
|
|
});
|
|
|
|
expect(metadata.locations).toEqual([
|
|
{ path: '/file1.ts', line: 10 },
|
|
{ path: '/file2.ts', line: null },
|
|
{ path: '/file3.ts', line: null },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Fix 6: Empty plan emission when args has todos', () => {
|
|
it('should emit empty plan when args had todos but result has none', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo-empty',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: null, // No result display
|
|
args: {
|
|
todos: [], // Empty array in args
|
|
},
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'plan',
|
|
entries: [],
|
|
});
|
|
});
|
|
|
|
it('should emit empty plan when result todos is empty but args had todos', async () => {
|
|
await emitter.emitResult({
|
|
toolName: TodoWriteTool.Name,
|
|
callId: 'call-todo-cleared',
|
|
success: true,
|
|
message: [],
|
|
resultDisplay: {
|
|
type: 'todo_list',
|
|
todos: [], // Empty result
|
|
},
|
|
args: {
|
|
todos: [{ id: '1', content: 'Was here', status: 'pending' }],
|
|
},
|
|
});
|
|
|
|
// Should still emit empty plan (result takes precedence but we emit empty)
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'plan',
|
|
entries: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Message transformation', () => {
|
|
it('should transform text parts from message', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-text',
|
|
success: true,
|
|
message: [{ text: 'Text content from message' }],
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-text',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: { type: 'text', text: 'Text content from message' },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should transform functionResponse parts from message', async () => {
|
|
await emitter.emitResult({
|
|
toolName: 'test_tool',
|
|
callId: 'call-func-resp',
|
|
success: true,
|
|
message: [
|
|
{
|
|
functionResponse: {
|
|
name: 'test_tool',
|
|
response: { output: 'Function output' },
|
|
},
|
|
},
|
|
],
|
|
resultDisplay: 'raw result',
|
|
});
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-func-resp',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: { type: 'text', text: 'Function output' },
|
|
},
|
|
],
|
|
rawOutput: 'raw result',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|