/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SchedulerStateManager } from './state-manager.js'; import type { ValidatingToolCall, WaitingToolCall, SuccessfulToolCall, ErroredToolCall, CancelledToolCall, ExecutingToolCall, ToolCallRequestInfo, ToolCallResponseInfo, } from './types.js'; import { ToolConfirmationOutcome, type AnyDeclarativeTool, type AnyToolInvocation, } from '../tools/tools.js'; import { MessageBusType } from '../confirmation-bus/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ROOT_SCHEDULER_ID } from './types.js'; describe('SchedulerStateManager', () => { const mockRequest: ToolCallRequestInfo = { callId: 'call-1', name: 'test-tool', args: { foo: 'bar' }, isClientInitiated: false, prompt_id: 'prompt-1', }; const mockTool = { name: 'test-tool', displayName: 'Test Tool', } as AnyDeclarativeTool; const mockInvocation = { shouldConfirmExecute: vi.fn(), } as unknown as AnyToolInvocation; const createValidatingCall = (id = 'call-1'): ValidatingToolCall => ({ status: 'validating', request: { ...mockRequest, callId: id }, tool: mockTool, invocation: mockInvocation, startTime: Date.now(), }); const createMockResponse = (id: string): ToolCallResponseInfo => ({ callId: id, responseParts: [], resultDisplay: 'Success', error: undefined, errorType: undefined, }); let stateManager: SchedulerStateManager; let mockMessageBus: MessageBus; let onUpdate: (calls: unknown[]) => void; beforeEach(() => { onUpdate = vi.fn(); mockMessageBus = { publish: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), } as unknown as MessageBus; // Capture the update when published vi.mocked(mockMessageBus.publish).mockImplementation((msg) => { // Return a Promise to satisfy the void | Promise signature if needed, // though typically mocks handle it. if (msg.type === MessageBusType.TOOL_CALLS_UPDATE) { onUpdate(msg.toolCalls); } return Promise.resolve(); }); stateManager = new SchedulerStateManager(mockMessageBus); }); describe('Observer Callback', () => { it('should trigger onTerminalCall when finalizing a call', () => { const onTerminalCall = vi.fn(); const manager = new SchedulerStateManager( mockMessageBus, ROOT_SCHEDULER_ID, onTerminalCall, ); const call = createValidatingCall(); manager.enqueue([call]); manager.dequeue(); manager.updateStatus( call.request.callId, 'success', createMockResponse(call.request.callId), ); manager.finalizeCall(call.request.callId); expect(onTerminalCall).toHaveBeenCalledTimes(1); expect(onTerminalCall).toHaveBeenCalledWith( expect.objectContaining({ status: 'success', request: expect.objectContaining({ callId: call.request.callId }), }), ); }); it('should trigger onTerminalCall for every call in cancelAllQueued', () => { const onTerminalCall = vi.fn(); const manager = new SchedulerStateManager( mockMessageBus, ROOT_SCHEDULER_ID, onTerminalCall, ); manager.enqueue([createValidatingCall('1'), createValidatingCall('2')]); manager.cancelAllQueued('Test cancel'); expect(onTerminalCall).toHaveBeenCalledTimes(2); expect(onTerminalCall).toHaveBeenCalledWith( expect.objectContaining({ status: 'cancelled', request: expect.objectContaining({ callId: '1' }), }), ); expect(onTerminalCall).toHaveBeenCalledWith( expect.objectContaining({ status: 'cancelled', request: expect.objectContaining({ callId: '2' }), }), ); }); }); describe('Initialization', () => { it('should start with empty state', () => { expect(stateManager.isActive).toBe(false); expect(stateManager.activeCallCount).toBe(0); expect(stateManager.queueLength).toBe(0); expect(stateManager.getSnapshot()).toEqual([]); }); }); describe('Lookup Operations', () => { it('should find tool calls in active calls', () => { const call = createValidatingCall('active-1'); stateManager.enqueue([call]); stateManager.dequeue(); expect(stateManager.getToolCall('active-1')).toEqual(call); }); it('should find tool calls in the queue', () => { const call = createValidatingCall('queued-1'); stateManager.enqueue([call]); expect(stateManager.getToolCall('queued-1')).toEqual(call); }); it('should find tool calls in the completed batch', () => { const call = createValidatingCall('completed-1'); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus( 'completed-1', 'success', createMockResponse('completed-1'), ); stateManager.finalizeCall('completed-1'); expect(stateManager.getToolCall('completed-1')).toBeDefined(); }); it('should return undefined for non-existent callIds', () => { expect(stateManager.getToolCall('void')).toBeUndefined(); }); }); describe('Queue Management', () => { it('should enqueue calls and notify', () => { const call = createValidatingCall(); stateManager.enqueue([call]); expect(stateManager.queueLength).toBe(1); expect(onUpdate).toHaveBeenCalledWith([call]); }); it('should dequeue calls and notify', () => { const call = createValidatingCall(); stateManager.enqueue([call]); const dequeued = stateManager.dequeue(); expect(dequeued).toEqual(call); expect(stateManager.queueLength).toBe(0); expect(stateManager.activeCallCount).toBe(1); expect(onUpdate).toHaveBeenCalled(); }); it('should return undefined when dequeueing from empty queue', () => { const dequeued = stateManager.dequeue(); expect(dequeued).toBeUndefined(); }); }); describe('Status Transitions', () => { it('should transition validating to scheduled', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus(call.request.callId, 'scheduled'); const snapshot = stateManager.getSnapshot(); expect(snapshot[0].status).toBe('scheduled'); expect(snapshot[0].request.callId).toBe(call.request.callId); }); it('should transition scheduled to executing', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus(call.request.callId, 'scheduled'); stateManager.updateStatus(call.request.callId, 'executing'); expect(stateManager.firstActiveCall?.status).toBe('executing'); }); it('should transition to success and move to completed batch', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const response: ToolCallResponseInfo = { callId: call.request.callId, responseParts: [], resultDisplay: 'Success', error: undefined, errorType: undefined, }; vi.mocked(onUpdate).mockClear(); stateManager.updateStatus(call.request.callId, 'success', response); expect(onUpdate).toHaveBeenCalledTimes(1); vi.mocked(onUpdate).mockClear(); stateManager.finalizeCall(call.request.callId); expect(onUpdate).toHaveBeenCalledTimes(1); expect(stateManager.isActive).toBe(false); expect(stateManager.completedBatch).toHaveLength(1); const completed = stateManager.completedBatch[0] as SuccessfulToolCall; expect(completed.status).toBe('success'); expect(completed.response).toEqual(response); expect(completed.durationMs).toBeDefined(); }); it('should transition to error and move to completed batch', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const response: ToolCallResponseInfo = { callId: call.request.callId, responseParts: [], resultDisplay: 'Error', error: new Error('Failed'), errorType: undefined, }; stateManager.updateStatus(call.request.callId, 'error', response); stateManager.finalizeCall(call.request.callId); expect(stateManager.isActive).toBe(false); expect(stateManager.completedBatch).toHaveLength(1); const completed = stateManager.completedBatch[0] as ErroredToolCall; expect(completed.status).toBe('error'); expect(completed.response).toEqual(response); }); it('should transition to awaiting_approval with details', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const details = { type: 'info' as const, title: 'Confirm', prompt: 'Proceed?', onConfirm: vi.fn(), }; stateManager.updateStatus( call.request.callId, 'awaiting_approval', details, ); const active = stateManager.firstActiveCall as WaitingToolCall; expect(active.status).toBe('awaiting_approval'); expect(active.confirmationDetails).toEqual(details); }); it('should transition to awaiting_approval with event-driven format', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const details = { type: 'info' as const, title: 'Confirm', prompt: 'Proceed?', }; const eventDrivenData = { correlationId: 'corr-123', confirmationDetails: details, }; stateManager.updateStatus( call.request.callId, 'awaiting_approval', eventDrivenData, ); const active = stateManager.firstActiveCall as WaitingToolCall; expect(active.status).toBe('awaiting_approval'); expect(active.correlationId).toBe('corr-123'); expect(active.confirmationDetails).toEqual(details); }); it('should preserve diff when cancelling an edit tool call', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const details = { type: 'edit' as const, title: 'Edit', fileName: 'test.txt', filePath: '/path/to/test.txt', fileDiff: 'diff', originalContent: 'old', newContent: 'new', onConfirm: vi.fn(), }; stateManager.updateStatus( call.request.callId, 'awaiting_approval', details, ); stateManager.updateStatus( call.request.callId, 'cancelled', 'User said no', ); stateManager.finalizeCall(call.request.callId); const completed = stateManager.completedBatch[0] as CancelledToolCall; expect(completed.status).toBe('cancelled'); expect(completed.response.resultDisplay).toEqual({ fileDiff: 'diff', fileName: 'test.txt', filePath: '/path/to/test.txt', originalContent: 'old', newContent: 'new', }); }); it('should ignore status updates for non-existent callIds', () => { stateManager.updateStatus('unknown', 'scheduled'); expect(onUpdate).not.toHaveBeenCalled(); }); it('should ignore status updates for terminal calls', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus( call.request.callId, 'success', createMockResponse(call.request.callId), ); stateManager.finalizeCall(call.request.callId); vi.mocked(onUpdate).mockClear(); stateManager.updateStatus(call.request.callId, 'scheduled'); expect(onUpdate).not.toHaveBeenCalled(); }); it('should only finalize terminal calls', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus(call.request.callId, 'executing'); stateManager.finalizeCall(call.request.callId); expect(stateManager.isActive).toBe(true); expect(stateManager.completedBatch).toHaveLength(0); stateManager.updateStatus( call.request.callId, 'success', createMockResponse(call.request.callId), ); stateManager.finalizeCall(call.request.callId); expect(stateManager.isActive).toBe(false); expect(stateManager.completedBatch).toHaveLength(1); }); it('should merge liveOutput and pid during executing updates', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); // Start executing stateManager.updateStatus(call.request.callId, 'executing'); let active = stateManager.firstActiveCall as ExecutingToolCall; expect(active.status).toBe('executing'); expect(active.liveOutput).toBeUndefined(); // Update with live output stateManager.updateStatus(call.request.callId, 'executing', { liveOutput: 'chunk 1', }); active = stateManager.firstActiveCall as ExecutingToolCall; expect(active.liveOutput).toBe('chunk 1'); // Update with pid (should preserve liveOutput) stateManager.updateStatus(call.request.callId, 'executing', { pid: 1234, }); active = stateManager.firstActiveCall as ExecutingToolCall; expect(active.liveOutput).toBe('chunk 1'); expect(active.pid).toBe(1234); // Update live output again (should preserve pid) stateManager.updateStatus(call.request.callId, 'executing', { liveOutput: 'chunk 2', }); active = stateManager.firstActiveCall as ExecutingToolCall; expect(active.liveOutput).toBe('chunk 2'); expect(active.pid).toBe(1234); }); }); describe('Argument Updates', () => { it('should update args and invocation', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); const newArgs = { foo: 'updated' }; const newInvocation = { ...mockInvocation } as AnyToolInvocation; stateManager.updateArgs(call.request.callId, newArgs, newInvocation); const active = stateManager.firstActiveCall; if (active && 'invocation' in active) { expect(active.invocation).toEqual(newInvocation); } else { throw new Error('Active call should have invocation'); } }); it('should ignore arg updates for errored calls', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus( call.request.callId, 'error', createMockResponse(call.request.callId), ); stateManager.finalizeCall(call.request.callId); stateManager.updateArgs( call.request.callId, { foo: 'new' }, mockInvocation, ); const completed = stateManager.completedBatch[0]; expect(completed.request.args).toEqual(mockRequest.args); }); }); describe('Outcome Tracking', () => { it('should set outcome and notify', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.setOutcome( call.request.callId, ToolConfirmationOutcome.ProceedAlways, ); const active = stateManager.firstActiveCall; expect(active?.outcome).toBe(ToolConfirmationOutcome.ProceedAlways); expect(onUpdate).toHaveBeenCalled(); }); }); describe('Batch Operations', () => { it('should cancel all queued calls', () => { stateManager.enqueue([ createValidatingCall('1'), createValidatingCall('2'), ]); vi.mocked(onUpdate).mockClear(); stateManager.cancelAllQueued('Batch cancel'); expect(stateManager.queueLength).toBe(0); expect(stateManager.completedBatch).toHaveLength(2); expect( stateManager.completedBatch.every((c) => c.status === 'cancelled'), ).toBe(true); expect(onUpdate).toHaveBeenCalledTimes(1); }); it('should not notify if cancelAllQueued is called on an empty queue', () => { vi.mocked(onUpdate).mockClear(); stateManager.cancelAllQueued('Batch cancel'); expect(onUpdate).not.toHaveBeenCalled(); }); it('should clear batch and notify', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus( call.request.callId, 'success', createMockResponse(call.request.callId), ); stateManager.finalizeCall(call.request.callId); stateManager.clearBatch(); expect(stateManager.completedBatch).toHaveLength(0); expect(onUpdate).toHaveBeenCalledWith([]); }); it('should return a copy of the completed batch (defensive)', () => { const call = createValidatingCall(); stateManager.enqueue([call]); stateManager.dequeue(); stateManager.updateStatus( call.request.callId, 'success', createMockResponse(call.request.callId), ); stateManager.finalizeCall(call.request.callId); const batch = stateManager.completedBatch; expect(batch).toHaveLength(1); // Mutate the returned array batch.pop(); expect(batch).toHaveLength(0); // Verify internal state is unchanged expect(stateManager.completedBatch).toHaveLength(1); }); }); describe('Snapshot and Ordering', () => { it('should return snapshot in order: completed, active, queue', () => { // 1. Completed const call1 = createValidatingCall('1'); stateManager.enqueue([call1]); stateManager.dequeue(); stateManager.updateStatus('1', 'success', createMockResponse('1')); stateManager.finalizeCall('1'); // 2. Active const call2 = createValidatingCall('2'); stateManager.enqueue([call2]); stateManager.dequeue(); // 3. Queue const call3 = createValidatingCall('3'); stateManager.enqueue([call3]); const snapshot = stateManager.getSnapshot(); expect(snapshot).toHaveLength(3); expect(snapshot[0].request.callId).toBe('1'); expect(snapshot[1].request.callId).toBe('2'); expect(snapshot[2].request.callId).toBe('3'); }); }); });