diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 431c78ab48..b580f2fed6 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -29,7 +29,13 @@ import { type ResumedSessionData, AuthType, type AgentDefinition, + MessageBusType, + QuestionType, } from '@google/gemini-cli-core'; +import { + AskUserActionsContext, + type AskUserState, +} from './contexts/AskUserActionsContext.js'; // Mock coreEvents const mockCoreEvents = vi.hoisted(() => ({ @@ -107,9 +113,11 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; +let capturedAskUserRequest: AskUserState | null; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; + capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null; return null; } @@ -259,6 +267,7 @@ describe('AppContainer State Management', () => { mocks.mockStdout.write.mockClear(); capturedUIState = null!; + capturedAskUserRequest = null; // **Provide a default return value for EVERY mocked hook.** mockedUseQuotaAndFallback.mockReturnValue({ @@ -2498,6 +2507,41 @@ describe('AppContainer State Management', () => { unmount!(); }); + + it('should show ask user dialog when request is received', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + const questions = [ + { + question: 'What is your favorite color?', + header: 'Color Preference', + type: QuestionType.TEXT, + }, + ]; + + await act(async () => { + await mockConfig.getMessageBus().publish({ + type: MessageBusType.ASK_USER_REQUEST, + questions, + correlationId: 'test-id', + }); + }); + + await waitFor( + () => { + expect(capturedAskUserRequest).not.toBeNull(); + expect(capturedAskUserRequest?.questions).toEqual(questions); + expect(capturedAskUserRequest?.correlationId).toBe('test-id'); + }, + { timeout: 2000 }, + ); + + unmount!(); + }); }); describe('Regression Tests', () => { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 61ee78f498..2a9be3f7b5 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -8,14 +8,19 @@ import { renderWithProviders, createMockSettings, } from '../../../test-utils/render.js'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { Scrollable } from '../shared/Scrollable.js'; -import type { Config } from '@google/gemini-cli-core'; +import { ASK_USER_DISPLAY_NAME, makeFakeConfig } from '@google/gemini-cli-core'; +import os from 'node:os'; describe('', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + const createToolCall = ( overrides: Partial = {}, ): IndividualToolCallDisplay => ({ @@ -35,16 +40,16 @@ describe('', () => { isFocused: true, }; - const baseMockConfig = { - getModel: () => 'gemini-pro', - getTargetDir: () => '/test', - getDebugMode: () => false, - isTrustedFolder: () => true, - getIdeMode: () => false, - getEnableInteractiveShell: () => true, - getPreviewFeatures: () => false, - isEventDrivenSchedulerEnabled: () => true, - } as unknown as Config; + const baseMockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + debugMode: false, + folderTrust: false, + ideMode: false, + enableInteractiveShell: true, + previewFeatures: false, + enableEventDrivenScheduler: true, + }); describe('Golden Snapshots', () => { it('renders single successful tool call', () => { @@ -83,10 +88,11 @@ describe('', () => { status: ToolCallStatus.Error, }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -116,10 +122,11 @@ describe('', () => { }, }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -177,10 +184,11 @@ describe('', () => { status: ToolCallStatus.Pending, }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -381,10 +389,11 @@ describe('', () => { describe('Border Color Logic', () => { it('uses yellow border when tools are pending', () => { const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -503,10 +512,11 @@ describe('', () => { }, }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -539,10 +549,11 @@ describe('', () => { const settings = createMockSettings({ security: { enablePermanentToolApproval: true }, }); - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -574,10 +585,11 @@ describe('', () => { }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => false, - } as unknown as Config; + const mockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + enableEventDrivenScheduler: false, + }); const { lastFrame, unmount } = renderWithProviders( , @@ -604,10 +616,7 @@ describe('', () => { }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => true, - } as unknown as Config; + const mockConfig = baseMockConfig; const { lastFrame, unmount } = renderWithProviders( , @@ -640,10 +649,7 @@ describe('', () => { }), ]; - const mockConfig = { - ...baseMockConfig, - isEventDrivenSchedulerEnabled: () => true, - } as unknown as Config; + const mockConfig = baseMockConfig; const { lastFrame, unmount } = renderWithProviders( , @@ -658,4 +664,72 @@ describe('', () => { unmount(); }); }); + + describe('Ask User Filtering', () => { + it.each([ + ToolCallStatus.Pending, + ToolCallStatus.Executing, + ToolCallStatus.Confirming, + ])('filters out ask_user when status is %s', (status) => { + const toolCalls = [ + createToolCall({ + callId: `ask-user-${status}`, + name: ASK_USER_DISPLAY_NAME, + status, + }), + ]; + + const { lastFrame, unmount } = renderWithProviders( + , + { config: baseMockConfig }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it.each([ToolCallStatus.Success, ToolCallStatus.Error])( + 'does NOT filter out ask_user when status is %s', + (status) => { + const toolCalls = [ + createToolCall({ + callId: `ask-user-${status}`, + name: ASK_USER_DISPLAY_NAME, + status, + }), + ]; + + const { lastFrame, unmount } = renderWithProviders( + , + { config: baseMockConfig }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }, + ); + + it('shows other tools when ask_user is filtered out', () => { + const toolCalls = [ + createToolCall({ + callId: 'other-tool', + name: 'other-tool', + status: ToolCallStatus.Success, + }), + createToolCall({ + callId: 'ask-user-pending', + name: ASK_USER_DISPLAY_NAME, + status: ToolCallStatus.Pending, + }), + ]; + + const { lastFrame, unmount } = renderWithProviders( + , + { config: baseMockConfig }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index e664288a06..422c3de760 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -1,5 +1,35 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ x Ask User │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Ask User │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`; + +exports[` > Ask User Filtering > filters out ask_user when status is Executing 1`] = `""`; + +exports[` > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`; + +exports[` > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ other-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + exports[` > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ diff --git a/packages/cli/src/ui/contexts/AskUserActionsContext.tsx b/packages/cli/src/ui/contexts/AskUserActionsContext.tsx index 5e77fce3e5..b76423505f 100644 --- a/packages/cli/src/ui/contexts/AskUserActionsContext.tsx +++ b/packages/cli/src/ui/contexts/AskUserActionsContext.tsx @@ -24,9 +24,8 @@ interface AskUserActionsContextValue { cancel: () => void; } -const AskUserActionsContext = createContext( - null, -); +export const AskUserActionsContext = + createContext(null); export const useAskUserActions = () => { const context = useContext(AskUserActionsContext);