mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 09:30:58 -07:00
1137 lines
34 KiB
TypeScript
1137 lines
34 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import type { Mock } from 'vitest';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { act } from 'react';
|
|
import { renderHook } from '../../test-utils/render.js';
|
|
import { useReactToolScheduler } from './useReactToolScheduler.js';
|
|
import { mapToDisplay } from './toolMapping.js';
|
|
import type { PartUnion, FunctionResponse } from '@google/genai';
|
|
import type {
|
|
Config,
|
|
ToolCallRequestInfo,
|
|
ToolRegistry,
|
|
ToolResult,
|
|
ToolCallConfirmationDetails,
|
|
ToolCallResponseInfo,
|
|
ToolCall, // Import from core
|
|
Status as ToolCallStatusType,
|
|
AnyDeclarativeTool,
|
|
AnyToolInvocation,
|
|
} from '@google/gemini-cli-core';
|
|
import {
|
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
|
ToolConfirmationOutcome,
|
|
ApprovalMode,
|
|
HookSystem,
|
|
PREVIEW_GEMINI_MODEL,
|
|
PolicyDecision,
|
|
} from '@google/gemini-cli-core';
|
|
import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
|
|
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
|
import { ToolCallStatus } from '../types.js';
|
|
|
|
// Mocks
|
|
vi.mock('@google/gemini-cli-core', async () => {
|
|
const actual = await vi.importActual<any>('@google/gemini-cli-core');
|
|
// Patch CoreToolScheduler to have cancelAll if it's missing in the test environment
|
|
if (
|
|
actual.CoreToolScheduler &&
|
|
!actual.CoreToolScheduler.prototype.cancelAll
|
|
) {
|
|
actual.CoreToolScheduler.prototype.cancelAll = vi.fn();
|
|
}
|
|
return {
|
|
...actual,
|
|
ToolRegistry: vi.fn(),
|
|
Config: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const mockToolRegistry = {
|
|
getTool: vi.fn(),
|
|
getAllToolNames: vi.fn(() => ['mockTool', 'anotherTool']),
|
|
};
|
|
|
|
const mockConfig = {
|
|
getToolRegistry: vi.fn(() => mockToolRegistry as unknown as ToolRegistry),
|
|
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
|
|
getSessionId: () => 'test-session-id',
|
|
getUsageStatisticsEnabled: () => true,
|
|
getDebugMode: () => false,
|
|
storage: {
|
|
getProjectTempDir: () => '/tmp',
|
|
},
|
|
getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
|
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
|
getAllowedTools: vi.fn(() => []),
|
|
getActiveModel: () => PREVIEW_GEMINI_MODEL,
|
|
getContentGeneratorConfig: () => ({
|
|
model: 'test-model',
|
|
authType: 'oauth-personal',
|
|
}),
|
|
getGeminiClient: () => null, // No client needed for these tests
|
|
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
|
|
getMessageBus: () => null,
|
|
isInteractive: () => false,
|
|
getExperiments: () => {},
|
|
getEnableHooks: () => false,
|
|
} as unknown as Config;
|
|
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
|
|
mockConfig.getHookSystem = vi.fn().mockReturnValue(new HookSystem(mockConfig));
|
|
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
|
|
check: async () => {
|
|
const mode = mockConfig.getApprovalMode();
|
|
if (mode === ApprovalMode.YOLO) {
|
|
return { decision: PolicyDecision.ALLOW };
|
|
}
|
|
return { decision: PolicyDecision.ASK_USER };
|
|
},
|
|
});
|
|
|
|
function createMockConfigOverride(overrides: Partial<Config> = {}): Config {
|
|
return { ...mockConfig, ...overrides } as Config;
|
|
}
|
|
|
|
const mockTool = new MockTool({
|
|
name: 'mockTool',
|
|
displayName: 'Mock Tool',
|
|
execute: vi.fn(),
|
|
shouldConfirmExecute: vi.fn(),
|
|
});
|
|
const mockToolWithLiveOutput = new MockTool({
|
|
name: 'mockToolWithLiveOutput',
|
|
displayName: 'Mock Tool With Live Output',
|
|
description: 'A mock tool for testing',
|
|
params: {},
|
|
isOutputMarkdown: true,
|
|
canUpdateOutput: true,
|
|
execute: vi.fn(),
|
|
shouldConfirmExecute: vi.fn(),
|
|
});
|
|
let mockOnUserConfirmForToolConfirmation: Mock;
|
|
const mockToolRequiresConfirmation = new MockTool({
|
|
name: 'mockToolRequiresConfirmation',
|
|
displayName: 'Mock Tool Requires Confirmation',
|
|
execute: vi.fn(),
|
|
shouldConfirmExecute: vi.fn(),
|
|
});
|
|
|
|
describe('useReactToolScheduler in YOLO Mode', () => {
|
|
let onComplete: Mock;
|
|
|
|
beforeEach(() => {
|
|
onComplete = vi.fn();
|
|
mockToolRegistry.getTool.mockClear();
|
|
(mockToolRequiresConfirmation.execute as Mock).mockClear();
|
|
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
|
|
|
|
// IMPORTANT: Enable YOLO mode for this test suite
|
|
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
|
|
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
// IMPORTANT: Disable YOLO mode after this test suite
|
|
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT);
|
|
});
|
|
|
|
const renderSchedulerInYoloMode = () =>
|
|
renderHook(() =>
|
|
useReactToolScheduler(
|
|
onComplete,
|
|
mockConfig as unknown as Config,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
it('should skip confirmation and execute tool directly when yoloMode is true', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation);
|
|
const expectedOutput = 'YOLO Confirmed output';
|
|
(mockToolRequiresConfirmation.execute as Mock).mockResolvedValue({
|
|
llmContent: expectedOutput,
|
|
returnDisplay: 'YOLO Formatted tool output',
|
|
} as ToolResult);
|
|
|
|
const { result } = renderSchedulerInYoloMode();
|
|
const schedule = result.current[1];
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'yoloCall',
|
|
name: 'mockToolRequiresConfirmation',
|
|
args: { data: 'any data' },
|
|
} as any;
|
|
|
|
await act(async () => {
|
|
await schedule(request, new AbortController().signal);
|
|
});
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0); // Process validation
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0); // Process scheduling
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0); // Process execution
|
|
});
|
|
|
|
// Check that execute WAS called
|
|
expect(mockToolRequiresConfirmation.execute).toHaveBeenCalledWith(
|
|
request.args,
|
|
);
|
|
|
|
// Check that onComplete was called with success
|
|
expect(onComplete).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
request,
|
|
response: expect.objectContaining({
|
|
resultDisplay: 'YOLO Formatted tool output',
|
|
responseParts: [
|
|
{
|
|
functionResponse: {
|
|
id: 'yoloCall',
|
|
name: 'mockToolRequiresConfirmation',
|
|
response: { output: expectedOutput },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('useReactToolScheduler', () => {
|
|
let onComplete: Mock;
|
|
let capturedOnConfirmForTest:
|
|
| ((outcome: ToolConfirmationOutcome) => void | Promise<void>)
|
|
| undefined;
|
|
|
|
const advanceAndSettle = async () => {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
};
|
|
|
|
const scheduleAndWaitForExecution = async (
|
|
schedule: (
|
|
req: ToolCallRequestInfo | ToolCallRequestInfo[],
|
|
signal: AbortSignal,
|
|
) => Promise<void>,
|
|
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
|
) => {
|
|
await act(async () => {
|
|
await schedule(request, new AbortController().signal);
|
|
});
|
|
|
|
await advanceAndSettle();
|
|
await advanceAndSettle();
|
|
await advanceAndSettle();
|
|
};
|
|
|
|
beforeEach(() => {
|
|
onComplete = vi.fn();
|
|
capturedOnConfirmForTest = undefined;
|
|
|
|
mockToolRegistry.getTool.mockClear();
|
|
(mockTool.execute as Mock).mockClear();
|
|
(mockTool.shouldConfirmExecute as Mock).mockClear();
|
|
(mockToolWithLiveOutput.execute as Mock).mockClear();
|
|
(mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockClear();
|
|
(mockToolRequiresConfirmation.execute as Mock).mockClear();
|
|
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
|
|
|
|
mockOnUserConfirmForToolConfirmation = vi.fn();
|
|
(
|
|
mockToolRequiresConfirmation.shouldConfirmExecute as Mock
|
|
).mockImplementation(
|
|
async (): Promise<ToolCallConfirmationDetails | null> =>
|
|
({
|
|
onConfirm: mockOnUserConfirmForToolConfirmation,
|
|
fileName: 'mockToolRequiresConfirmation.ts',
|
|
fileDiff: 'Mock tool requires confirmation',
|
|
type: 'edit',
|
|
title: 'Mock Tool Requires Confirmation',
|
|
}) as any,
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const renderScheduler = (config: Config = mockConfig) =>
|
|
renderHook(() =>
|
|
useReactToolScheduler(onComplete, config, () => undefined),
|
|
);
|
|
|
|
it('initial state should be empty', () => {
|
|
const { result } = renderScheduler();
|
|
expect(result.current[0]).toEqual([]);
|
|
});
|
|
|
|
it('should schedule and execute a tool call successfully', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
(mockTool.execute as Mock).mockResolvedValue({
|
|
llmContent: 'Tool output',
|
|
returnDisplay: 'Formatted tool output',
|
|
} as ToolResult);
|
|
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
|
|
|
const { result } = renderScheduler();
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'call1',
|
|
name: 'mockTool',
|
|
args: { param: 'value' },
|
|
} as any;
|
|
|
|
let completedToolCalls: ToolCall[] = [];
|
|
onComplete.mockImplementation((calls) => {
|
|
completedToolCalls = calls;
|
|
});
|
|
|
|
await scheduleAndWaitForExecution(result.current[1], request);
|
|
|
|
expect(mockTool.execute).toHaveBeenCalledWith(request.args);
|
|
expect(completedToolCalls).toHaveLength(1);
|
|
expect(completedToolCalls[0].status).toBe('success');
|
|
expect(completedToolCalls[0].request).toBe(request);
|
|
|
|
if (
|
|
completedToolCalls[0].status === 'success' ||
|
|
completedToolCalls[0].status === 'error'
|
|
) {
|
|
expect(completedToolCalls[0].response).toMatchSnapshot();
|
|
}
|
|
});
|
|
|
|
it('should clear previous tool calls when scheduling new ones', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
(mockTool.execute as Mock).mockImplementation(async () => {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
return {
|
|
llmContent: 'Tool output',
|
|
returnDisplay: 'Formatted tool output',
|
|
};
|
|
});
|
|
|
|
const { result } = renderScheduler();
|
|
const schedule = result.current[1];
|
|
const setToolCallsForDisplay = result.current[3];
|
|
|
|
// Manually set a tool call in the display.
|
|
const oldToolCall = {
|
|
request: { callId: 'oldCall' },
|
|
status: 'success',
|
|
} as any;
|
|
act(() => {
|
|
setToolCallsForDisplay([oldToolCall]);
|
|
});
|
|
expect(result.current[0]).toEqual([oldToolCall]);
|
|
|
|
const newRequest: ToolCallRequestInfo = {
|
|
callId: 'newCall',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any;
|
|
let schedulePromise: Promise<void>;
|
|
await act(async () => {
|
|
schedulePromise = schedule(newRequest, new AbortController().signal);
|
|
});
|
|
|
|
await advanceAndSettle();
|
|
|
|
// After scheduling, the old call should be gone,
|
|
// and the new one should be in the display in its initial state.
|
|
expect(result.current[0].length).toBe(1);
|
|
expect(result.current[0][0].request.callId).toBe('newCall');
|
|
expect(result.current[0][0].request.callId).not.toBe('oldCall');
|
|
|
|
// Let the new call finish.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
});
|
|
|
|
await act(async () => {
|
|
await schedulePromise;
|
|
});
|
|
|
|
expect(onComplete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should cancel all running tool calls', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
|
|
let resolveExecute: (value: ToolResult) => void = () => {};
|
|
const executePromise = new Promise<ToolResult>((resolve) => {
|
|
resolveExecute = resolve;
|
|
});
|
|
(mockTool.execute as Mock).mockReturnValue(executePromise);
|
|
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
|
|
|
const { result } = renderScheduler();
|
|
const schedule = result.current[1];
|
|
const cancelAllToolCalls = result.current[4];
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'cancelCall',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any;
|
|
|
|
let schedulePromise: Promise<void>;
|
|
await act(async () => {
|
|
schedulePromise = schedule(request, new AbortController().signal);
|
|
});
|
|
|
|
await advanceAndSettle(); // validation
|
|
await advanceAndSettle(); // Process scheduling
|
|
|
|
// At this point, the tool is 'executing' and waiting on the promise.
|
|
expect(result.current[0][0].status).toBe('executing');
|
|
|
|
const cancelController = new AbortController();
|
|
act(() => {
|
|
cancelAllToolCalls(cancelController.signal);
|
|
});
|
|
|
|
await advanceAndSettle();
|
|
|
|
expect(onComplete).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
status: 'cancelled',
|
|
request,
|
|
}),
|
|
]);
|
|
|
|
// Clean up the pending promise to avoid open handles.
|
|
await act(async () => {
|
|
resolveExecute({ llmContent: 'output', returnDisplay: 'display' });
|
|
});
|
|
|
|
// Now await the schedule promise
|
|
await act(async () => {
|
|
await schedulePromise;
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
desc: 'tool not found',
|
|
setup: () => {
|
|
mockToolRegistry.getTool.mockReturnValue(undefined);
|
|
},
|
|
request: {
|
|
callId: 'call1',
|
|
name: 'nonexistentTool',
|
|
args: {},
|
|
} as any,
|
|
expectedErrorContains: [
|
|
'Tool "nonexistentTool" not found in registry',
|
|
'Did you mean one of:',
|
|
],
|
|
},
|
|
{
|
|
desc: 'error during shouldConfirmExecute',
|
|
setup: () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
const confirmError = new Error('Confirmation check failed');
|
|
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
|
|
},
|
|
request: {
|
|
callId: 'call1',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any,
|
|
expectedError: new Error('Confirmation check failed'),
|
|
},
|
|
{
|
|
desc: 'error during execute',
|
|
setup: () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
|
const execError = new Error('Execution failed');
|
|
(mockTool.execute as Mock).mockRejectedValue(execError);
|
|
},
|
|
request: {
|
|
callId: 'call1',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any,
|
|
expectedError: new Error('Execution failed'),
|
|
},
|
|
])(
|
|
'should handle $desc',
|
|
async ({ setup, request, expectedErrorContains, expectedError }) => {
|
|
setup();
|
|
const { result } = renderScheduler();
|
|
|
|
let completedToolCalls: ToolCall[] = [];
|
|
onComplete.mockImplementation((calls) => {
|
|
completedToolCalls = calls;
|
|
});
|
|
|
|
await scheduleAndWaitForExecution(result.current[1], request);
|
|
|
|
expect(completedToolCalls).toHaveLength(1);
|
|
expect(completedToolCalls[0].status).toBe('error');
|
|
expect(completedToolCalls[0].request).toBe(request);
|
|
|
|
if (expectedErrorContains) {
|
|
expectedErrorContains.forEach((errorText) => {
|
|
expect(
|
|
(completedToolCalls[0] as any).response.error.message,
|
|
).toContain(errorText);
|
|
});
|
|
}
|
|
|
|
if (expectedError) {
|
|
expect((completedToolCalls[0] as any).response.error.message).toBe(
|
|
expectedError.message,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it('should handle tool requiring confirmation - approved', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation);
|
|
const config = createMockConfigOverride({
|
|
isInteractive: () => true,
|
|
});
|
|
const expectedOutput = 'Confirmed output';
|
|
(mockToolRequiresConfirmation.execute as Mock).mockResolvedValue({
|
|
llmContent: expectedOutput,
|
|
returnDisplay: 'Confirmed display',
|
|
} as ToolResult);
|
|
|
|
const { result } = renderScheduler(config);
|
|
const schedule = result.current[1];
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'callConfirm',
|
|
name: 'mockToolRequiresConfirmation',
|
|
args: { data: 'sensitive' },
|
|
} as any;
|
|
|
|
let schedulePromise: Promise<void>;
|
|
await act(async () => {
|
|
schedulePromise = schedule(request, new AbortController().signal);
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
const waitingCall = result.current[0][0] as any;
|
|
expect(waitingCall.status).toBe('awaiting_approval');
|
|
capturedOnConfirmForTest = waitingCall.confirmationDetails?.onConfirm;
|
|
expect(capturedOnConfirmForTest).toBeDefined();
|
|
|
|
await act(async () => {
|
|
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.ProceedOnce);
|
|
});
|
|
|
|
await advanceAndSettle();
|
|
|
|
// Now await the schedule promise as it should complete
|
|
await act(async () => {
|
|
await schedulePromise;
|
|
});
|
|
|
|
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
|
|
ToolConfirmationOutcome.ProceedOnce,
|
|
);
|
|
expect(mockToolRequiresConfirmation.execute).toHaveBeenCalled();
|
|
|
|
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
|
|
expect(completedCalls[0].status).toBe('success');
|
|
expect(completedCalls[0].request).toBe(request);
|
|
if (
|
|
completedCalls[0].status === 'success' ||
|
|
completedCalls[0].status === 'error'
|
|
) {
|
|
expect(completedCalls[0].response).toMatchSnapshot();
|
|
}
|
|
});
|
|
|
|
it('should handle tool requiring confirmation - cancelled by user', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation);
|
|
const config = createMockConfigOverride({
|
|
isInteractive: () => true,
|
|
});
|
|
const { result } = renderScheduler(config);
|
|
const schedule = result.current[1];
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'callConfirmCancel',
|
|
name: 'mockToolRequiresConfirmation',
|
|
args: {},
|
|
} as any;
|
|
|
|
let schedulePromise: Promise<void>;
|
|
await act(async () => {
|
|
schedulePromise = schedule(request, new AbortController().signal);
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
const waitingCall = result.current[0][0] as any;
|
|
expect(waitingCall.status).toBe('awaiting_approval');
|
|
capturedOnConfirmForTest = waitingCall.confirmationDetails?.onConfirm;
|
|
expect(capturedOnConfirmForTest).toBeDefined();
|
|
|
|
await act(async () => {
|
|
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel);
|
|
});
|
|
|
|
await advanceAndSettle();
|
|
|
|
// Now await the schedule promise
|
|
await act(async () => {
|
|
await schedulePromise;
|
|
});
|
|
|
|
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
|
|
ToolConfirmationOutcome.Cancel,
|
|
);
|
|
|
|
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
|
|
expect(completedCalls[0].status).toBe('cancelled');
|
|
expect(completedCalls[0].request).toBe(request);
|
|
if (
|
|
completedCalls[0].status === 'success' ||
|
|
completedCalls[0].status === 'error' ||
|
|
completedCalls[0].status === 'cancelled'
|
|
) {
|
|
expect(completedCalls[0].response).toMatchSnapshot();
|
|
}
|
|
});
|
|
|
|
it('should handle live output updates', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockToolWithLiveOutput);
|
|
let liveUpdateFn: ((output: string) => void) | undefined;
|
|
let resolveExecutePromise: (value: ToolResult) => void;
|
|
const executePromise = new Promise<ToolResult>((resolve) => {
|
|
resolveExecutePromise = resolve;
|
|
});
|
|
|
|
(mockToolWithLiveOutput.execute as Mock).mockImplementation(
|
|
async (
|
|
_args: Record<string, unknown>,
|
|
_signal: AbortSignal,
|
|
updateFn: ((output: string) => void) | undefined,
|
|
) => {
|
|
liveUpdateFn = updateFn;
|
|
return executePromise;
|
|
},
|
|
);
|
|
(mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockResolvedValue(
|
|
null,
|
|
);
|
|
|
|
const { result } = renderScheduler();
|
|
const request: ToolCallRequestInfo = {
|
|
callId: 'liveCall',
|
|
name: 'mockToolWithLiveOutput',
|
|
args: {},
|
|
} as any;
|
|
|
|
let schedulePromise: Promise<void>;
|
|
await act(async () => {
|
|
schedulePromise = result.current[1](
|
|
request,
|
|
new AbortController().signal,
|
|
);
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
expect(liveUpdateFn).toBeDefined();
|
|
expect(result.current[0][0].status).toBe('executing');
|
|
|
|
await act(async () => {
|
|
liveUpdateFn?.('Live output 1');
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
await act(async () => {
|
|
liveUpdateFn?.('Live output 2');
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
act(() => {
|
|
resolveExecutePromise({
|
|
llmContent: 'Final output',
|
|
returnDisplay: 'Final display',
|
|
} as ToolResult);
|
|
});
|
|
await advanceAndSettle();
|
|
|
|
// Now await schedule
|
|
await act(async () => {
|
|
await schedulePromise;
|
|
});
|
|
|
|
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
|
|
expect(completedCalls[0].status).toBe('success');
|
|
expect(completedCalls[0].request).toBe(request);
|
|
if (
|
|
completedCalls[0].status === 'success' ||
|
|
completedCalls[0].status === 'error'
|
|
) {
|
|
expect(completedCalls[0].response).toMatchSnapshot();
|
|
}
|
|
expect(result.current[0]).toEqual([]);
|
|
});
|
|
|
|
it('should schedule and execute multiple tool calls', async () => {
|
|
const tool1 = new MockTool({
|
|
name: 'tool1',
|
|
displayName: 'Tool 1',
|
|
execute: vi.fn().mockResolvedValue({
|
|
llmContent: 'Output 1',
|
|
returnDisplay: 'Display 1',
|
|
} as ToolResult),
|
|
});
|
|
|
|
const tool2 = new MockTool({
|
|
name: 'tool2',
|
|
displayName: 'Tool 2',
|
|
execute: vi.fn().mockResolvedValue({
|
|
llmContent: 'Output 2',
|
|
returnDisplay: 'Display 2',
|
|
} as ToolResult),
|
|
});
|
|
|
|
mockToolRegistry.getTool.mockImplementation((name) => {
|
|
if (name === 'tool1') return tool1;
|
|
if (name === 'tool2') return tool2;
|
|
return undefined;
|
|
});
|
|
|
|
const { result } = renderScheduler();
|
|
const schedule = result.current[1];
|
|
const requests: ToolCallRequestInfo[] = [
|
|
{ callId: 'multi1', name: 'tool1', args: { p: 1 } } as any,
|
|
{ callId: 'multi2', name: 'tool2', args: { p: 2 } } as any,
|
|
];
|
|
|
|
await act(async () => {
|
|
await schedule(requests, new AbortController().signal);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
|
|
expect(completedCalls.length).toBe(2);
|
|
|
|
const call1Result = completedCalls.find(
|
|
(c) => c.request.callId === 'multi1',
|
|
);
|
|
const call2Result = completedCalls.find(
|
|
(c) => c.request.callId === 'multi2',
|
|
);
|
|
|
|
expect(call1Result).toMatchObject({
|
|
status: 'success',
|
|
request: requests[0],
|
|
response: expect.objectContaining({
|
|
resultDisplay: 'Display 1',
|
|
responseParts: [
|
|
{
|
|
functionResponse: {
|
|
id: 'multi1',
|
|
name: 'tool1',
|
|
response: { output: 'Output 1' },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
expect(call2Result).toMatchObject({
|
|
status: 'success',
|
|
request: requests[1],
|
|
response: expect.objectContaining({
|
|
resultDisplay: 'Display 2',
|
|
responseParts: [
|
|
{
|
|
functionResponse: {
|
|
id: 'multi2',
|
|
name: 'tool2',
|
|
response: { output: 'Output 2' },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
expect(completedCalls).toHaveLength(2);
|
|
expect(completedCalls.every((t) => t.status === 'success')).toBe(true);
|
|
});
|
|
|
|
it('should queue if scheduling while already running', async () => {
|
|
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
|
const longExecutePromise = new Promise<ToolResult>((resolve) =>
|
|
setTimeout(
|
|
() =>
|
|
resolve({
|
|
llmContent: 'done',
|
|
returnDisplay: 'done display',
|
|
}),
|
|
50,
|
|
),
|
|
);
|
|
(mockTool.execute as Mock).mockReturnValue(longExecutePromise);
|
|
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
|
|
|
const { result } = renderScheduler();
|
|
const schedule = result.current[1];
|
|
const request1: ToolCallRequestInfo = {
|
|
callId: 'run1',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any;
|
|
const request2: ToolCallRequestInfo = {
|
|
callId: 'run2',
|
|
name: 'mockTool',
|
|
args: {},
|
|
} as any;
|
|
|
|
let schedulePromise1: Promise<void>;
|
|
let schedulePromise2: Promise<void>;
|
|
|
|
await act(async () => {
|
|
schedulePromise1 = schedule(request1, new AbortController().signal);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
await act(async () => {
|
|
schedulePromise2 = schedule(request2, new AbortController().signal);
|
|
});
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
// Wait for first to complete
|
|
await act(async () => {
|
|
await schedulePromise1;
|
|
});
|
|
|
|
expect(onComplete).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
request: request1,
|
|
response: expect.objectContaining({ resultDisplay: 'done display' }),
|
|
}),
|
|
]);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
// Wait for second to complete
|
|
await act(async () => {
|
|
await schedulePromise2;
|
|
});
|
|
|
|
expect(onComplete).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
request: request2,
|
|
response: expect.objectContaining({ resultDisplay: 'done display' }),
|
|
}),
|
|
]);
|
|
const toolCalls = result.current[0];
|
|
expect(toolCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('mapToDisplay', () => {
|
|
const baseRequest: ToolCallRequestInfo = {
|
|
callId: 'testCallId',
|
|
name: 'testTool',
|
|
args: { foo: 'bar' },
|
|
} as any;
|
|
|
|
const baseTool = new MockTool({
|
|
name: 'testTool',
|
|
displayName: 'Test Tool Display',
|
|
execute: vi.fn(),
|
|
shouldConfirmExecute: vi.fn(),
|
|
});
|
|
|
|
const baseResponse: ToolCallResponseInfo = {
|
|
callId: 'testCallId',
|
|
responseParts: [
|
|
{
|
|
functionResponse: {
|
|
name: 'testTool',
|
|
id: 'testCallId',
|
|
response: { output: 'Test output' },
|
|
} as FunctionResponse,
|
|
} as PartUnion,
|
|
],
|
|
resultDisplay: 'Test display output',
|
|
error: undefined,
|
|
} as any;
|
|
|
|
// Define a more specific type for extraProps for these tests
|
|
// This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist.
|
|
type MapToDisplayExtraProps =
|
|
| {
|
|
tool?: AnyDeclarativeTool;
|
|
invocation?: AnyToolInvocation;
|
|
liveOutput?: string;
|
|
response?: ToolCallResponseInfo;
|
|
confirmationDetails?: ToolCallConfirmationDetails;
|
|
}
|
|
| {
|
|
tool: AnyDeclarativeTool;
|
|
invocation?: AnyToolInvocation;
|
|
response?: ToolCallResponseInfo;
|
|
confirmationDetails?: ToolCallConfirmationDetails;
|
|
}
|
|
| {
|
|
response: ToolCallResponseInfo;
|
|
tool?: undefined;
|
|
confirmationDetails?: ToolCallConfirmationDetails;
|
|
}
|
|
| {
|
|
confirmationDetails: ToolCallConfirmationDetails;
|
|
tool?: AnyDeclarativeTool;
|
|
invocation?: AnyToolInvocation;
|
|
response?: ToolCallResponseInfo;
|
|
};
|
|
|
|
const baseInvocation = baseTool.build(baseRequest.args);
|
|
const testCases: Array<{
|
|
name: string;
|
|
status: ToolCallStatusType;
|
|
extraProps?: MapToDisplayExtraProps;
|
|
expectedStatus: ToolCallStatus;
|
|
expectedResultDisplay?: string;
|
|
expectedName?: string;
|
|
expectedDescription?: string;
|
|
}> = [
|
|
{
|
|
name: 'validating',
|
|
status: 'validating',
|
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
|
expectedStatus: ToolCallStatus.Executing,
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'awaiting_approval',
|
|
status: 'awaiting_approval',
|
|
extraProps: {
|
|
tool: baseTool,
|
|
invocation: baseInvocation,
|
|
confirmationDetails: {
|
|
onConfirm: vi.fn(),
|
|
type: 'edit',
|
|
title: 'Test Tool Display',
|
|
serverName: 'testTool',
|
|
toolName: 'testTool',
|
|
toolDisplayName: 'Test Tool Display',
|
|
filePath: 'mock',
|
|
fileName: 'test.ts',
|
|
fileDiff: 'Test diff',
|
|
originalContent: 'Original content',
|
|
newContent: 'New content',
|
|
} as ToolCallConfirmationDetails,
|
|
},
|
|
expectedStatus: ToolCallStatus.Confirming,
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'scheduled',
|
|
status: 'scheduled',
|
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
|
expectedStatus: ToolCallStatus.Pending,
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'executing no live output',
|
|
status: 'executing',
|
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
|
expectedStatus: ToolCallStatus.Executing,
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'executing with live output',
|
|
status: 'executing',
|
|
extraProps: {
|
|
tool: baseTool,
|
|
invocation: baseInvocation,
|
|
liveOutput: 'Live test output',
|
|
},
|
|
expectedStatus: ToolCallStatus.Executing,
|
|
expectedResultDisplay: 'Live test output',
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'success',
|
|
status: 'success',
|
|
extraProps: {
|
|
tool: baseTool,
|
|
invocation: baseInvocation,
|
|
response: baseResponse,
|
|
},
|
|
expectedStatus: ToolCallStatus.Success,
|
|
expectedResultDisplay: baseResponse.resultDisplay as any,
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
{
|
|
name: 'error tool not found',
|
|
status: 'error',
|
|
extraProps: {
|
|
response: {
|
|
...baseResponse,
|
|
error: new Error('Test error tool not found'),
|
|
resultDisplay: 'Error display tool not found',
|
|
},
|
|
},
|
|
expectedStatus: ToolCallStatus.Error,
|
|
expectedResultDisplay: 'Error display tool not found',
|
|
expectedName: baseRequest.name,
|
|
expectedDescription: JSON.stringify(baseRequest.args),
|
|
},
|
|
{
|
|
name: 'error tool execution failed',
|
|
status: 'error',
|
|
extraProps: {
|
|
tool: baseTool,
|
|
response: {
|
|
...baseResponse,
|
|
error: new Error('Tool execution failed'),
|
|
resultDisplay: 'Execution failed display',
|
|
},
|
|
},
|
|
expectedStatus: ToolCallStatus.Error,
|
|
expectedResultDisplay: 'Execution failed display',
|
|
expectedName: baseTool.displayName, // Changed from baseTool.name
|
|
expectedDescription: JSON.stringify(baseRequest.args),
|
|
},
|
|
{
|
|
name: 'cancelled',
|
|
status: 'cancelled',
|
|
extraProps: {
|
|
tool: baseTool,
|
|
invocation: baseInvocation,
|
|
response: {
|
|
...baseResponse,
|
|
resultDisplay: 'Cancelled display',
|
|
},
|
|
},
|
|
expectedStatus: ToolCallStatus.Canceled,
|
|
expectedResultDisplay: 'Cancelled display',
|
|
expectedName: baseTool.displayName,
|
|
expectedDescription: baseInvocation.getDescription(),
|
|
},
|
|
];
|
|
|
|
testCases.forEach(
|
|
({
|
|
name: testName,
|
|
status,
|
|
extraProps,
|
|
expectedStatus,
|
|
expectedResultDisplay,
|
|
expectedName,
|
|
expectedDescription,
|
|
}) => {
|
|
it(`should map ToolCall with status '${status}' (${testName}) correctly`, () => {
|
|
const toolCall: ToolCall = {
|
|
request: baseRequest,
|
|
status,
|
|
...(extraProps || {}),
|
|
} as ToolCall;
|
|
|
|
const display = mapToDisplay(toolCall);
|
|
expect(display.type).toBe('tool_group');
|
|
expect(display.tools.length).toBe(1);
|
|
const toolDisplay = display.tools[0];
|
|
|
|
expect(toolDisplay.callId).toBe(baseRequest.callId);
|
|
expect(toolDisplay.status).toBe(expectedStatus);
|
|
expect(toolDisplay.resultDisplay).toBe(expectedResultDisplay);
|
|
|
|
expect(toolDisplay.name).toBe(expectedName);
|
|
expect(toolDisplay.description).toBe(expectedDescription);
|
|
|
|
expect(toolDisplay.renderOutputAsMarkdown).toBe(
|
|
extraProps?.tool?.isOutputMarkdown ?? false,
|
|
);
|
|
if (status === 'awaiting_approval') {
|
|
expect(toolDisplay.confirmationDetails).toBe(
|
|
extraProps!.confirmationDetails,
|
|
);
|
|
} else {
|
|
expect(toolDisplay.confirmationDetails).toBeUndefined();
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
it('should map an array of ToolCalls correctly', () => {
|
|
const toolCall1: ToolCall = {
|
|
request: { ...baseRequest, callId: 'call1' },
|
|
status: 'success',
|
|
tool: baseTool,
|
|
invocation: baseTool.build(baseRequest.args),
|
|
response: { ...baseResponse, callId: 'call1' },
|
|
} as ToolCall;
|
|
const toolForCall2 = new MockTool({
|
|
name: baseTool.name,
|
|
displayName: baseTool.displayName,
|
|
isOutputMarkdown: true,
|
|
execute: vi.fn(),
|
|
shouldConfirmExecute: vi.fn(),
|
|
});
|
|
const toolCall2: ToolCall = {
|
|
request: { ...baseRequest, callId: 'call2' },
|
|
status: 'executing',
|
|
tool: toolForCall2,
|
|
invocation: toolForCall2.build(baseRequest.args),
|
|
liveOutput: 'markdown output',
|
|
} as ToolCall;
|
|
|
|
const display = mapToDisplay([toolCall1, toolCall2]);
|
|
expect(display.tools.length).toBe(2);
|
|
expect(display.tools[0].callId).toBe('call1');
|
|
expect(display.tools[0].status).toBe(ToolCallStatus.Success);
|
|
expect(display.tools[0].renderOutputAsMarkdown).toBe(false);
|
|
expect(display.tools[1].callId).toBe('call2');
|
|
expect(display.tools[1].status).toBe(ToolCallStatus.Executing);
|
|
expect(display.tools[1].resultDisplay).toBe('markdown output');
|
|
expect(display.tools[1].renderOutputAsMarkdown).toBe(true);
|
|
});
|
|
});
|