refactor(cli): finalize event-driven transition and remove interaction bridge (#18569)

This commit is contained in:
Abhi
2026-02-13 11:14:35 +09:00
committed by GitHub
parent b62c6566be
commit 00f73b73bc
16 changed files with 104 additions and 397 deletions

View File

@@ -7,7 +7,6 @@
import {
type ToolCall,
type Status as CoreStatus,
type ToolCallConfirmationDetails,
type SerializableConfirmationDetails,
type ToolResultDisplay,
debugLogger,
@@ -76,10 +75,8 @@ export function mapToDisplay(
};
let resultDisplay: ToolResultDisplay | undefined = undefined;
let confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined = undefined;
let confirmationDetails: SerializableConfirmationDetails | undefined =
undefined;
let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;

View File

@@ -32,6 +32,7 @@ import {
GeminiEventType as ServerGeminiEventType,
ToolErrorType,
ToolConfirmationOutcome,
MessageBusType,
tokenLimit,
debugLogger,
coreEvents,
@@ -49,6 +50,11 @@ const mockSendMessageStream = vi
.fn()
.mockReturnValue((async function* () {})());
const mockStartChat = vi.fn();
const mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
};
const MockedGeminiClientClass = vi.hoisted(() =>
vi.fn().mockImplementation(function (this: any, _config: any) {
@@ -250,6 +256,7 @@ describe('useGeminiStream', () => {
isJitContextEnabled: vi.fn(() => false),
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getMessageBus: vi.fn(() => mockMessageBus),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
} as unknown as Config;
@@ -399,7 +406,6 @@ describe('useGeminiStream', () => {
toolName: string,
callId: string,
confirmationType: 'edit' | 'info',
mockOnConfirm: Mock,
status: TrackedToolCall['status'] = 'awaiting_approval',
): TrackedWaitingToolCall => ({
request: {
@@ -416,7 +422,6 @@ describe('useGeminiStream', () => {
? {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
@@ -426,7 +431,6 @@ describe('useGeminiStream', () => {
: {
type: 'info',
title: `${toolName} confirmation`,
onConfirm: mockOnConfirm,
prompt: `Execute ${toolName}?`,
},
tool: {
@@ -438,6 +442,7 @@ describe('useGeminiStream', () => {
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
correlationId: `corr-${callId}`,
});
// Helper to render hook with default parameters - reduces boilerplate
@@ -1763,10 +1768,9 @@ describe('useGeminiStream', () => {
describe('handleApprovalModeChange', () => {
it('should auto-approve all pending tool calls when switching to YOLO mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
createMockToolCall('read_file', 'call2', 'info', mockOnConfirm),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('read_file', 'call2', 'info'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1776,21 +1780,27 @@ describe('useGeminiStream', () => {
});
// Both tool calls should be auto-approved
expect(mockOnConfirm).toHaveBeenCalledTimes(2);
expect(mockOnConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-call1',
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
correlationId: 'corr-call2',
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should only auto-approve edit tools when switching to AUTO_EDIT mode', async () => {
const mockOnConfirmReplace = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmWrite = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmRead = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmReplace),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmWrite),
createMockToolCall('read_file', 'call3', 'info', mockOnConfirmRead),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('write_file', 'call2', 'edit'),
createMockToolCall('read_file', 'call3', 'info'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1800,21 +1810,21 @@ describe('useGeminiStream', () => {
});
// Only replace and write_file should be auto-approved
expect(mockOnConfirmReplace).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call1' }),
);
expect(mockOnConfirmWrite).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call2' }),
);
expect(mockMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call3' }),
);
// read_file should not be auto-approved
expect(mockOnConfirmRead).not.toHaveBeenCalled();
});
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
createMockToolCall('replace', 'call1', 'edit'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1824,21 +1834,19 @@ describe('useGeminiStream', () => {
});
// No tools should be auto-approved
expect(mockOnConfirm).not.toHaveBeenCalled();
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('should handle errors gracefully when auto-approving tool calls', async () => {
const debuggerSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const mockOnConfirmSuccess = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmError = vi
.fn()
.mockRejectedValue(new Error('Approval failed'));
mockMessageBus.publish.mockRejectedValueOnce(new Error('Bus error'));
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmSuccess),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmError),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('write_file', 'call2', 'edit'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1847,13 +1855,10 @@ describe('useGeminiStream', () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Both confirmation methods should be called
expect(mockOnConfirmSuccess).toHaveBeenCalled();
expect(mockOnConfirmError).toHaveBeenCalled();
// Error should be logged
// Both should be attempted despite first error
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(debuggerSpy).toHaveBeenCalledWith(
'Failed to auto-approve tool call call2:',
'Failed to auto-approve tool call call1:',
expect.any(Error),
);
@@ -1882,6 +1887,7 @@ describe('useGeminiStream', () => {
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
correlationId: 'corr-1',
} as unknown as TrackedWaitingToolCall,
];
@@ -1893,83 +1899,9 @@ describe('useGeminiStream', () => {
});
});
it('should skip tool calls without onConfirm method in confirmationDetails', async () => {
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
// No onConfirm method
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
} as any,
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
// Should not throw an error
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
});
it('should only process tool calls with awaiting_approval status', async () => {
const mockOnConfirmAwaiting = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmExecuting = vi.fn().mockResolvedValue(undefined);
const mixedStatusToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmAwaiting,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit'),
{
request: {
callId: 'call2',
@@ -1991,6 +1923,7 @@ describe('useGeminiStream', () => {
} as unknown as AnyToolInvocation,
startTime: Date.now(),
liveOutput: 'Writing...',
correlationId: 'corr-call2',
} as TrackedExecutingToolCall,
];
@@ -2000,9 +1933,14 @@ describe('useGeminiStream', () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Only the awaiting_approval tool should be processed
expect(mockOnConfirmAwaiting).toHaveBeenCalledTimes(1);
expect(mockOnConfirmExecuting).not.toHaveBeenCalled();
// Only the awaiting_approval tool should be processed.
expect(mockMessageBus.publish).toHaveBeenCalledTimes(1);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call1' }),
);
expect(mockMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call2' }),
);
});
});

View File

@@ -20,6 +20,7 @@ import {
ApprovalMode,
parseAndFormatApiError,
ToolConfirmationOutcome,
MessageBusType,
promptIdContext,
tokenLimit,
debugLogger,
@@ -1408,10 +1409,15 @@ export const useGeminiStream = (
// Process pending tool calls sequentially to reduce UI chaos
for (const call of awaitingApprovalCalls) {
const details = call.confirmationDetails;
if (details && 'onConfirm' in details) {
if (call.correlationId) {
try {
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
await config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: call.correlationId,
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
});
} catch (error) {
debugLogger.warn(
`Failed to auto-approve tool call ${call.request.callId}:`,
@@ -1422,7 +1428,7 @@ export const useGeminiStream = (
}
}
},
[toolCalls],
[config, toolCalls],
);
const handleCompletedTools = useCallback(

View File

@@ -10,12 +10,10 @@ import { renderHook } from '../../test-utils/render.js';
import { useToolScheduler } from './useToolScheduler.js';
import {
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type Config,
type MessageBus,
type CompletedToolCall,
type ToolCallConfirmationDetails,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
type AnyToolInvocation,
@@ -132,122 +130,6 @@ describe('useToolScheduler', () => {
});
});
it('injects onConfirm callback for awaiting_approval tools (Adapter Pattern)', async () => {
const { result } = renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation({
getDescription: () => 'Confirming test tool',
}),
confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Sure?' },
correlationId: 'corr-123',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected status to be awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
expect(confirmationDetails).toBeDefined();
expect(typeof confirmationDetails.onConfirm).toBe('function');
// Test that onConfirm publishes to MessageBus
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-123',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: undefined,
});
});
it('injects onConfirm with payload (Inline Edit support)', async () => {
const { result } = renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
confirmationDetails: { type: 'edit', title: 'Edit', filePath: 'test.ts' },
correlationId: 'corr-edit',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
const mockPayload = { newContent: 'updated code' };
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
mockPayload,
);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-edit',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: mockPayload,
});
});
it('preserves responseSubmittedToGemini flag across updates', () => {
const { result } = renderHook(() =>
useToolScheduler(

View File

@@ -6,21 +6,18 @@
import {
type Config,
type MessageBus,
type ToolCallRequestInfo,
type ToolCall,
type CompletedToolCall,
type ToolConfirmationPayload,
MessageBusType,
ToolConfirmationOutcome,
ROOT_SCHEDULER_ID,
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
ROOT_SCHEDULER_ID,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
// Re-exporting types compatible with legacy hook expectations
// Re-exporting types compatible with hook expectations
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
@@ -109,8 +106,8 @@ export function useToolScheduler(
const internalAdaptToolCalls = useCallback(
(coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) =>
adaptToolCalls(coreCalls, prevTracked, messageBus),
[messageBus],
adaptToolCalls(coreCalls, prevTracked),
[],
);
useEffect(() => {
@@ -227,12 +224,11 @@ export function useToolScheduler(
}
/**
* ADAPTER: Merges UI metadata (submitted flag) and injects legacy callbacks.
* ADAPTER: Merges UI metadata (submitted flag).
*/
function adaptToolCalls(
coreCalls: ToolCall[],
prevTracked: TrackedToolCall[],
messageBus: MessageBus,
): TrackedToolCall[] {
const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t]));
@@ -240,34 +236,6 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
// Inject onConfirm adapter for tools awaiting approval.
// The Core provides data-only (serializable) confirmationDetails. We must
// inject the legacy callback function that proxies responses back to the
// MessageBus.
if (coreCall.status === 'awaiting_approval' && coreCall.correlationId) {
const correlationId = coreCall.correlationId;
return {
...coreCall,
confirmationDetails: {
...coreCall.confirmationDetails,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
requiresUserConfirmation: false,
outcome,
payload,
});
},
},
responseSubmittedToGemini,
};
}
return {
...coreCall,
responseSubmittedToGemini,