mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(core): Implement parallel FC for read only tools. (#18791)
This commit is contained in:
@@ -27,7 +27,6 @@ vi.mock('../telemetry/trace.js', () => ({
|
||||
}));
|
||||
|
||||
import { logToolCall } from '../telemetry/loggers.js';
|
||||
import { ToolCallEvent } from '../telemetry/types.js';
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logToolCall: vi.fn(),
|
||||
}));
|
||||
@@ -76,6 +75,8 @@ import type {
|
||||
CancelledToolCall,
|
||||
CompletedToolCall,
|
||||
ToolCallResponseInfo,
|
||||
Status,
|
||||
ToolCall,
|
||||
} from './types.js';
|
||||
import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
@@ -168,29 +169,55 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
getPreferredEditor = vi.fn().mockReturnValue('vim');
|
||||
|
||||
// --- Setup Sub-component Mocks ---
|
||||
const mockActiveCallsMap = new Map<string, ToolCall>();
|
||||
const mockQueue: ToolCall[] = [];
|
||||
|
||||
mockStateManager = {
|
||||
enqueue: vi.fn(),
|
||||
dequeue: vi.fn(),
|
||||
getToolCall: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
finalizeCall: vi.fn(),
|
||||
enqueue: vi.fn((calls: ToolCall[]) => {
|
||||
// Clone to preserve initial state for Phase 1 tests
|
||||
mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall));
|
||||
}),
|
||||
dequeue: vi.fn(() => {
|
||||
const next = mockQueue.shift();
|
||||
if (next) mockActiveCallsMap.set(next.request.callId, next);
|
||||
return next;
|
||||
}),
|
||||
peekQueue: vi.fn(() => mockQueue[0]),
|
||||
getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)),
|
||||
updateStatus: vi.fn((id: string, status: Status) => {
|
||||
const call = mockActiveCallsMap.get(id);
|
||||
if (call) (call as unknown as { status: Status }).status = status;
|
||||
}),
|
||||
finalizeCall: vi.fn((id: string) => {
|
||||
const call = mockActiveCallsMap.get(id);
|
||||
if (call) {
|
||||
mockActiveCallsMap.delete(id);
|
||||
capturedTerminalHandler?.(call as CompletedToolCall);
|
||||
}
|
||||
}),
|
||||
updateArgs: vi.fn(),
|
||||
setOutcome: vi.fn(),
|
||||
cancelAllQueued: vi.fn(),
|
||||
cancelAllQueued: vi.fn(() => {
|
||||
mockQueue.length = 0;
|
||||
}),
|
||||
clearBatch: vi.fn(),
|
||||
} as unknown as Mocked<SchedulerStateManager>;
|
||||
|
||||
// Define getters for accessors idiomatically
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(false),
|
||||
get: vi.fn(() => mockActiveCallsMap.size > 0),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockStateManager, 'allActiveCalls', {
|
||||
get: vi.fn(() => Array.from(mockActiveCallsMap.values())),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi.fn().mockReturnValue(0),
|
||||
get: vi.fn(() => mockQueue.length),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
get: vi.fn(() => mockActiveCallsMap.values().next().value),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockStateManager, 'completedBatch', {
|
||||
@@ -227,8 +254,9 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
);
|
||||
|
||||
mockStateManager.finalizeCall.mockImplementation((callId: string) => {
|
||||
const call = mockStateManager.getToolCall(callId);
|
||||
const call = mockActiveCallsMap.get(callId);
|
||||
if (call) {
|
||||
mockActiveCallsMap.delete(callId);
|
||||
capturedTerminalHandler?.(call as CompletedToolCall);
|
||||
}
|
||||
});
|
||||
@@ -242,6 +270,13 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
vi.mocked(ToolExecutor).mockReturnValue(
|
||||
mockExecutor as unknown as Mocked<ToolExecutor>,
|
||||
);
|
||||
mockExecutor.execute.mockResolvedValue({
|
||||
status: 'success',
|
||||
response: {
|
||||
callId: 'default',
|
||||
responseParts: [],
|
||||
} as unknown as ToolCallResponseInfo,
|
||||
} as unknown as SuccessfulToolCall);
|
||||
vi.mocked(ToolModificationHandler).mockReturnValue(
|
||||
mockModifier as unknown as Mocked<ToolModificationHandler>,
|
||||
);
|
||||
@@ -339,35 +374,6 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
|
||||
describe('Phase 2: Queue Management', () => {
|
||||
it('should drain the queue if multiple calls are scheduled', async () => {
|
||||
const validatingCall: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
// Setup queue simulation: two items
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(2)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(false),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockStateManager.dequeue.mockReturnValue(validatingCall);
|
||||
vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall);
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi.fn().mockReturnValue(validatingCall),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Execute is the end of the loop, stub it
|
||||
mockExecutor.execute.mockResolvedValue({
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -375,56 +381,12 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
|
||||
await scheduler.schedule(req1, signal);
|
||||
|
||||
// Verify loop ran twice
|
||||
expect(mockStateManager.dequeue).toHaveBeenCalledTimes(2);
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(2);
|
||||
// Verify loop ran once for this schedule call (which had 1 request)
|
||||
// schedule(req1) enqueues 1 request.
|
||||
expect(mockExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should execute tool calls sequentially (first completes before second starts)', async () => {
|
||||
// Setup queue simulation: two items
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(2)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(false),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const validatingCall1: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
const validatingCall2: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req2,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
vi.mocked(mockStateManager.dequeue)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2)
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(validatingCall1) // Used in loop check for call 1
|
||||
.mockReturnValueOnce(validatingCall1) // Used in _execute for call 1
|
||||
.mockReturnValueOnce(validatingCall2) // Used in loop check for call 2
|
||||
.mockReturnValueOnce(validatingCall2), // Used in _execute for call 2
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const executionLog: string[] = [];
|
||||
|
||||
// Mock executor to push to log with a deterministic microtask delay
|
||||
@@ -452,52 +414,6 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
});
|
||||
|
||||
it('should queue and process multiple schedule() calls made synchronously', async () => {
|
||||
const validatingCall1: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
const validatingCall2: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req2, // Second request
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
// Mock state responses dynamically
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(false),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Queue state responses for the two batches:
|
||||
// Batch 1: length 1 -> 0
|
||||
// Batch 2: length 1 -> 0
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.mocked(mockStateManager.dequeue)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2);
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2)
|
||||
.mockReturnValueOnce(validatingCall2),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Executor succeeds instantly
|
||||
mockExecutor.execute.mockResolvedValue({
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -516,50 +432,6 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
});
|
||||
|
||||
it('should queue requests when scheduler is busy (overlapping batches)', async () => {
|
||||
const validatingCall1: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
const validatingCall2: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req2, // Second request
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
// 1. Setup State Manager for 2 sequential batches
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(false),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(1) // Batch 1
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(1) // Batch 2
|
||||
.mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.mocked(mockStateManager.dequeue)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2);
|
||||
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2)
|
||||
.mockReturnValueOnce(validatingCall2),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// 2. Setup Executor with a controllable lock for the first batch
|
||||
const executionLog: string[] = [];
|
||||
let finishFirstBatch: (value: unknown) => void;
|
||||
@@ -635,10 +507,8 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi.fn().mockReturnValue(activeCall),
|
||||
configurable: true,
|
||||
});
|
||||
mockStateManager.enqueue([activeCall]);
|
||||
mockStateManager.dequeue();
|
||||
|
||||
scheduler.cancelAll();
|
||||
|
||||
@@ -676,24 +546,7 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
});
|
||||
|
||||
describe('Phase 3: Policy & Confirmation Loop', () => {
|
||||
const validatingCall: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall);
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi.fn().mockReturnValue(validatingCall),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
beforeEach(() => {});
|
||||
|
||||
it('should update state to error with POLICY_VIOLATION if Policy returns DENY', async () => {
|
||||
vi.mocked(checkPolicy).mockResolvedValue({
|
||||
@@ -854,30 +707,6 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
});
|
||||
|
||||
it('should auto-approve remaining identical tools in batch after ProceedAlways', async () => {
|
||||
// Setup: two identical tools
|
||||
const validatingCall1: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
const validatingCall2: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req2,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
vi.mocked(mockStateManager.dequeue)
|
||||
.mockReturnValueOnce(validatingCall1)
|
||||
.mockReturnValueOnce(validatingCall2)
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
vi.spyOn(mockStateManager, 'queueLength', 'get')
|
||||
.mockReturnValueOnce(2)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValue(0);
|
||||
|
||||
// First call requires confirmation, second is auto-approved (simulating policy update)
|
||||
vi.mocked(checkPolicy)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1045,21 +874,7 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
});
|
||||
|
||||
describe('Phase 4: Execution Outcomes', () => {
|
||||
const validatingCall: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStateManager, 'queueLength', 'get')
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValue(0);
|
||||
mockStateManager.dequeue.mockReturnValue(validatingCall);
|
||||
vi.spyOn(mockStateManager, 'firstActiveCall', 'get').mockReturnValue(
|
||||
validatingCall,
|
||||
);
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
}); // Bypass confirmation
|
||||
@@ -1132,30 +947,12 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
response: mockResponse,
|
||||
} as unknown as SuccessfulToolCall);
|
||||
|
||||
// Mock the state manager to return a SUCCESS state when getToolCall is
|
||||
// called
|
||||
const successfulCall: SuccessfulToolCall = {
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: req1,
|
||||
response: mockResponse,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
mockStateManager.getToolCall.mockReturnValue(successfulCall);
|
||||
Object.defineProperty(mockStateManager, 'completedBatch', {
|
||||
get: vi.fn().mockReturnValue([successfulCall]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await scheduler.schedule(req1, signal);
|
||||
|
||||
// Verify the finalizer and logger were called
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
expect(ToolCallEvent).toHaveBeenCalledWith(successfulCall);
|
||||
expect(logToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining(successfulCall),
|
||||
);
|
||||
// We check that logToolCall was called (it's called via the state manager's terminal handler)
|
||||
expect(logToolCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not double-report completed tools when concurrent completions occur', async () => {
|
||||
@@ -1182,6 +979,33 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
});
|
||||
|
||||
it('should break the loop if no progress is made (safeguard against stuck states)', async () => {
|
||||
// Setup: A tool that is 'validating' but stays 'validating' even after processing
|
||||
// This simulates a bug in state management or a weird edge case.
|
||||
const stuckCall: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
// Mock dequeue to keep returning the same stuck call
|
||||
mockStateManager.dequeue.mockReturnValue(stuckCall);
|
||||
// Mock isActive to be true
|
||||
Object.defineProperty(mockStateManager, 'isActive', {
|
||||
get: vi.fn().mockReturnValue(true),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock updateStatus to do NOTHING (simulating no progress)
|
||||
mockStateManager.updateStatus.mockImplementation(() => {});
|
||||
|
||||
// This should return false (break loop) instead of hanging indefinitely
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (scheduler as any)._processNextItem(signal);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Call Context Propagation', () => {
|
||||
@@ -1196,26 +1020,6 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
parentCallId,
|
||||
});
|
||||
|
||||
const validatingCall: ValidatingToolCall = {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation as unknown as AnyToolInvocation,
|
||||
};
|
||||
|
||||
// Mock queueLength to run the loop once
|
||||
Object.defineProperty(mockStateManager, 'queueLength', {
|
||||
get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall);
|
||||
Object.defineProperty(mockStateManager, 'firstActiveCall', {
|
||||
get: vi.fn().mockReturnValue(validatingCall),
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(mockStateManager.getToolCall).mockReturnValue(validatingCall);
|
||||
|
||||
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
|
||||
Reference in New Issue
Block a user