mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
475 lines
13 KiB
TypeScript
475 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { act } from 'react';
|
|
import { renderHook } from '../../test-utils/render.js';
|
|
import { useToolScheduler } from './useToolScheduler.js';
|
|
import {
|
|
MessageBusType,
|
|
Scheduler,
|
|
type Config,
|
|
type MessageBus,
|
|
type ExecutingToolCall,
|
|
type CompletedToolCall,
|
|
type ToolCallsUpdateMessage,
|
|
type AnyDeclarativeTool,
|
|
type AnyToolInvocation,
|
|
ROOT_SCHEDULER_ID,
|
|
CoreToolCallStatus,
|
|
} from '@google/gemini-cli-core';
|
|
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
|
|
|
// Mock Core Scheduler
|
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
|
return {
|
|
...actual,
|
|
Scheduler: vi.fn().mockImplementation(() => ({
|
|
schedule: vi.fn().mockResolvedValue([]),
|
|
cancelAll: vi.fn(),
|
|
})),
|
|
};
|
|
});
|
|
|
|
const createMockTool = (
|
|
overrides: Partial<AnyDeclarativeTool> = {},
|
|
): AnyDeclarativeTool =>
|
|
({
|
|
name: 'test_tool',
|
|
displayName: 'Test Tool',
|
|
description: 'A test tool',
|
|
kind: 'function',
|
|
parameterSchema: {},
|
|
isOutputMarkdown: false,
|
|
build: vi.fn(),
|
|
...overrides,
|
|
}) as AnyDeclarativeTool;
|
|
|
|
const createMockInvocation = (
|
|
overrides: Partial<AnyToolInvocation> = {},
|
|
): AnyToolInvocation =>
|
|
({
|
|
getDescription: () => 'Executing test tool',
|
|
shouldConfirmExecute: vi.fn(),
|
|
execute: vi.fn(),
|
|
params: {},
|
|
toolLocations: [],
|
|
...overrides,
|
|
}) as AnyToolInvocation;
|
|
|
|
describe('useToolScheduler', () => {
|
|
let mockConfig: Config;
|
|
let mockMessageBus: MessageBus;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockMessageBus = createMockMessageBus() as unknown as MessageBus;
|
|
mockConfig = {
|
|
getMessageBus: () => mockMessageBus,
|
|
} as unknown as Config;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('initializes with empty tool calls', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
const [toolCalls] = result.current;
|
|
expect(toolCalls).toEqual([]);
|
|
});
|
|
|
|
it('updates tool calls when MessageBus emits TOOL_CALLS_UPDATE', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const mockToolCall = {
|
|
status: CoreToolCallStatus.Executing as const,
|
|
request: {
|
|
callId: 'call-1',
|
|
name: 'test_tool',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
liveOutput: 'Loading...',
|
|
} as ExecutingToolCall;
|
|
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [mockToolCall],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
const [toolCalls] = result.current;
|
|
expect(toolCalls).toHaveLength(1);
|
|
// Expect Core Object structure, not Display Object
|
|
expect(toolCalls[0]).toMatchObject({
|
|
request: { callId: 'call-1', name: 'test_tool' },
|
|
status: CoreToolCallStatus.Executing,
|
|
liveOutput: 'Loading...',
|
|
responseSubmittedToGemini: false,
|
|
});
|
|
});
|
|
|
|
it('preserves responseSubmittedToGemini flag across updates', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const mockToolCall = {
|
|
status: CoreToolCallStatus.Success as const,
|
|
request: {
|
|
callId: 'call-1',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
response: {
|
|
callId: 'call-1',
|
|
resultDisplay: 'OK',
|
|
responseParts: [],
|
|
error: undefined,
|
|
errorType: undefined,
|
|
},
|
|
};
|
|
|
|
// 1. Initial success
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [mockToolCall],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
// 2. Mark as submitted
|
|
act(() => {
|
|
const [, , markAsSubmitted] = result.current;
|
|
markAsSubmitted(['call-1']);
|
|
});
|
|
|
|
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
|
|
|
|
// 3. Receive another update (should preserve the true flag)
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [mockToolCall],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
|
|
});
|
|
|
|
it('updates lastToolOutputTime when tools are executing', () => {
|
|
vi.useFakeTimers();
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [
|
|
{
|
|
status: CoreToolCallStatus.Executing as const,
|
|
request: {
|
|
callId: 'call-1',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
},
|
|
],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
const [, , , , , lastOutputTime] = result.current;
|
|
expect(lastOutputTime).toBeGreaterThan(startTime);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('delegates cancelAll to the Core Scheduler', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const [, , , , cancelAll] = result.current;
|
|
const signal = new AbortController().signal;
|
|
|
|
// We need to find the mock instance of Scheduler
|
|
// Since we used vi.mock at top level, we can get it from vi.mocked(Scheduler)
|
|
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
|
|
|
|
cancelAll(signal);
|
|
|
|
expect(schedulerInstance.cancelAll).toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the schedule promise when scheduler resolves', async () => {
|
|
const onComplete = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const completedToolCall = {
|
|
status: CoreToolCallStatus.Success as const,
|
|
request: {
|
|
callId: 'call-1',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
response: {
|
|
callId: 'call-1',
|
|
responseParts: [],
|
|
resultDisplay: 'Success',
|
|
error: undefined,
|
|
errorType: undefined,
|
|
},
|
|
};
|
|
|
|
// Mock the specific return value for this test
|
|
const { Scheduler } = await import('@google/gemini-cli-core');
|
|
vi.mocked(Scheduler).mockImplementation(
|
|
() =>
|
|
({
|
|
schedule: vi.fn().mockResolvedValue([completedToolCall]),
|
|
cancelAll: vi.fn(),
|
|
}) as unknown as Scheduler,
|
|
);
|
|
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(onComplete, mockConfig, () => undefined),
|
|
);
|
|
|
|
const [, schedule] = result.current;
|
|
const signal = new AbortController().signal;
|
|
|
|
let completedResult: CompletedToolCall[] = [];
|
|
await act(async () => {
|
|
completedResult = await schedule(
|
|
{
|
|
callId: 'call-1',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
signal,
|
|
);
|
|
});
|
|
|
|
expect(completedResult).toEqual([completedToolCall]);
|
|
expect(onComplete).toHaveBeenCalledWith([completedToolCall]);
|
|
});
|
|
|
|
it('setToolCallsForDisplay re-groups tools by schedulerId (Multi-Scheduler support)', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const callRoot = {
|
|
status: CoreToolCallStatus.Success as const,
|
|
request: {
|
|
callId: 'call-root',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
response: {
|
|
callId: 'call-root',
|
|
responseParts: [],
|
|
resultDisplay: 'OK',
|
|
error: undefined,
|
|
errorType: undefined,
|
|
},
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
};
|
|
|
|
const callSub = {
|
|
...callRoot,
|
|
request: { ...callRoot.request, callId: 'call-sub' },
|
|
schedulerId: 'subagent-1',
|
|
};
|
|
|
|
// 1. Populate state with multiple schedulers
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [callRoot],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [callSub],
|
|
schedulerId: 'subagent-1',
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
const [toolCalls] = result.current;
|
|
expect(toolCalls).toHaveLength(1);
|
|
expect(toolCalls[0].request.callId).toBe('call-root');
|
|
expect(toolCalls[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
|
|
|
|
// 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear)
|
|
act(() => {
|
|
const [, , , setToolCalls] = result.current;
|
|
setToolCalls((prev) =>
|
|
prev.map((t) => ({ ...t, responseSubmittedToGemini: true })),
|
|
);
|
|
});
|
|
|
|
// 3. Verify that tools are still present and maintain their scheduler IDs
|
|
const [toolCalls2] = result.current;
|
|
expect(toolCalls2).toHaveLength(1);
|
|
expect(toolCalls2[0].responseSubmittedToGemini).toBe(true);
|
|
expect(toolCalls2[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
|
|
});
|
|
|
|
it('ignores TOOL_CALLS_UPDATE from non-root schedulers', () => {
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const subagentCall = {
|
|
status: CoreToolCallStatus.Executing as const,
|
|
request: {
|
|
callId: 'call-sub',
|
|
name: 'test',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
schedulerId: 'subagent-1',
|
|
};
|
|
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [subagentCall],
|
|
schedulerId: 'subagent-1',
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
const [toolCalls] = result.current;
|
|
expect(toolCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('adapts success/error status to executing when a tail call is present', () => {
|
|
vi.useFakeTimers();
|
|
const { result } = renderHook(() =>
|
|
useToolScheduler(
|
|
vi.fn().mockResolvedValue(undefined),
|
|
mockConfig,
|
|
() => undefined,
|
|
),
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const mockToolCall = {
|
|
status: CoreToolCallStatus.Success as const,
|
|
request: {
|
|
callId: 'call-1',
|
|
name: 'test_tool',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: 'p1',
|
|
},
|
|
tool: createMockTool(),
|
|
invocation: createMockInvocation(),
|
|
response: {
|
|
callId: 'call-1',
|
|
resultDisplay: 'OK',
|
|
responseParts: [],
|
|
error: undefined,
|
|
errorType: undefined,
|
|
},
|
|
tailToolCallRequest: {
|
|
name: 'tail_tool',
|
|
args: {},
|
|
isClientInitiated: false,
|
|
prompt_id: '123',
|
|
},
|
|
};
|
|
|
|
act(() => {
|
|
void mockMessageBus.publish({
|
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
|
toolCalls: [mockToolCall],
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
} as ToolCallsUpdateMessage);
|
|
});
|
|
|
|
const [toolCalls, , , , , lastOutputTime] = result.current;
|
|
|
|
// Check if status has been adapted to 'executing'
|
|
expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
|
|
|
|
// Check if lastOutputTime was updated due to the transitional state
|
|
expect(lastOutputTime).toBeGreaterThan(startTime);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|