/** * @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(); return { ...actual, Scheduler: vi.fn().mockImplementation(() => ({ schedule: vi.fn().mockResolvedValue([]), cancelAll: vi.fn(), })), }; }); const createMockTool = ( overrides: Partial = {}, ): 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 => ({ 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(); }); });