2025-05-29 22:30:18 +00:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-06-02 01:50:28 -07:00
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe , it , expect , vi , beforeEach , Mock } from 'vitest' ;
import { renderHook , act , waitFor } from '@testing-library/react' ;
import { useGeminiStream , mergePartListUnions } from './useGeminiStream.js' ;
import {
useReactToolScheduler ,
TrackedToolCall ,
TrackedCompletedToolCall ,
TrackedExecutingToolCall ,
TrackedCancelledToolCall ,
} from './useReactToolScheduler.js' ;
2025-06-07 14:27:22 -07:00
import { Config } from '@gemini-cli/core' ;
2025-05-29 22:30:18 +00:00
import { Part , PartListUnion } from '@google/genai' ;
2025-06-02 01:50:28 -07:00
import { UseHistoryManagerReturn } from './useHistoryManager.js' ;
2025-05-29 22:30:18 +00:00
2025-06-02 01:50:28 -07:00
// --- MOCKS ---
const mockSendMessageStream = vi
. fn ( )
. mockReturnValue ( ( async function * ( ) { } ) ( ) ) ;
const mockStartChat = vi . fn ( ) ;
2025-06-02 22:30:52 -07:00
const MockedGeminiClientClass = vi . hoisted ( ( ) = >
vi . fn ( ) . mockImplementation ( function ( this : any , _config : any ) {
2025-06-02 01:50:28 -07:00
// _config
this . startChat = mockStartChat ;
this . sendMessageStream = mockSendMessageStream ;
2025-06-02 22:30:52 -07:00
} ) ,
) ;
2025-06-07 14:27:22 -07:00
vi . mock ( '@gemini-cli/core' , async ( importOriginal ) = > {
2025-06-02 22:30:52 -07:00
const actualCoreModule = ( await importOriginal ( ) ) as any ;
2025-06-02 01:50:28 -07:00
return {
. . . ( actualCoreModule || { } ) ,
2025-06-02 22:30:52 -07:00
GeminiClient : MockedGeminiClientClass , // Export the class for type checking or other direct uses
Config : actualCoreModule.Config , // Ensure Config is passed through
2025-06-02 01:50:28 -07:00
} ;
} ) ;
const mockUseReactToolScheduler = useReactToolScheduler as Mock ;
vi . mock ( './useReactToolScheduler.js' , async ( importOriginal ) = > {
const actualSchedulerModule = ( await importOriginal ( ) ) as any ;
2025-05-29 22:30:18 +00:00
return {
2025-06-02 01:50:28 -07:00
. . . ( actualSchedulerModule || { } ) ,
2025-06-01 14:16:24 -07:00
useReactToolScheduler : vi.fn ( ) ,
2025-05-29 22:30:18 +00:00
} ;
} ) ;
2025-06-02 01:50:28 -07:00
vi . mock ( 'ink' , async ( importOriginal ) = > {
const actualInkModule = ( await importOriginal ( ) ) as any ;
return { . . . ( actualInkModule || { } ) , useInput : vi.fn ( ) } ;
} ) ;
vi . mock ( './shellCommandProcessor.js' , ( ) = > ( {
useShellCommandProcessor : vi.fn ( ) . mockReturnValue ( {
handleShellCommand : vi.fn ( ) ,
} ) ,
} ) ) ;
vi . mock ( './atCommandProcessor.js' , ( ) = > ( {
handleAtCommand : vi
. fn ( )
. mockResolvedValue ( { shouldProceed : true , processedQuery : 'mocked' } ) ,
} ) ) ;
vi . mock ( '../utils/markdownUtilities.js' , ( ) = > ( {
findLastSafeSplitPoint : vi.fn ( ( s : string ) = > s . length ) ,
} ) ) ;
vi . mock ( './useStateAndRef.js' , ( ) = > ( {
useStateAndRef : vi.fn ( ( initial ) = > {
let val = initial ;
const ref = { current : val } ;
const setVal = vi . fn ( ( updater ) = > {
if ( typeof updater === 'function' ) {
val = updater ( val ) ;
} else {
val = updater ;
}
ref . current = val ;
} ) ;
return [ ref , setVal ] ;
} ) ,
} ) ) ;
vi . mock ( './useLogger.js' , ( ) = > ( {
useLogger : vi.fn ( ) . mockReturnValue ( {
logMessage : vi.fn ( ) . mockResolvedValue ( undefined ) ,
} ) ,
} ) ) ;
vi . mock ( './slashCommandProcessor.js' , ( ) = > ( {
handleSlashCommand : vi.fn ( ) . mockReturnValue ( false ) ,
} ) ) ;
// --- END MOCKS ---
2025-05-29 22:30:18 +00:00
describe ( 'mergePartListUnions' , ( ) = > {
it ( 'should merge multiple PartListUnion arrays' , ( ) = > {
const list1 : PartListUnion = [ { text : 'Hello' } ] ;
const list2 : PartListUnion = [
{ inlineData : { mimeType : 'image/png' , data : 'abc' } } ,
] ;
const list3 : PartListUnion = [ { text : 'World' } , { text : '!' } ] ;
const result = mergePartListUnions ( [ list1 , list2 , list3 ] ) ;
expect ( result ) . toEqual ( [
{ text : 'Hello' } ,
{ inlineData : { mimeType : 'image/png' , data : 'abc' } } ,
{ text : 'World' } ,
{ text : '!' } ,
] ) ;
} ) ;
it ( 'should handle empty arrays in the input list' , ( ) = > {
const list1 : PartListUnion = [ { text : 'First' } ] ;
const list2 : PartListUnion = [ ] ;
const list3 : PartListUnion = [ { text : 'Last' } ] ;
const result = mergePartListUnions ( [ list1 , list2 , list3 ] ) ;
expect ( result ) . toEqual ( [ { text : 'First' } , { text : 'Last' } ] ) ;
} ) ;
it ( 'should handle a single PartListUnion array' , ( ) = > {
const list1 : PartListUnion = [
{ text : 'One' } ,
{ inlineData : { mimeType : 'image/jpeg' , data : 'xyz' } } ,
] ;
const result = mergePartListUnions ( [ list1 ] ) ;
expect ( result ) . toEqual ( list1 ) ;
} ) ;
it ( 'should return an empty array if all input arrays are empty' , ( ) = > {
const list1 : PartListUnion = [ ] ;
const list2 : PartListUnion = [ ] ;
const result = mergePartListUnions ( [ list1 , list2 ] ) ;
expect ( result ) . toEqual ( [ ] ) ;
} ) ;
it ( 'should handle input list being empty' , ( ) = > {
const result = mergePartListUnions ( [ ] ) ;
expect ( result ) . toEqual ( [ ] ) ;
} ) ;
it ( 'should correctly merge when PartListUnion items are single Parts not in arrays' , ( ) = > {
const part1 : Part = { text : 'Single part 1' } ;
const part2 : Part = { inlineData : { mimeType : 'image/gif' , data : 'gif' } } ;
const listContainingSingleParts : PartListUnion [ ] = [
part1 ,
[ part2 ] ,
{ text : 'Another single part' } ,
] ;
const result = mergePartListUnions ( listContainingSingleParts ) ;
expect ( result ) . toEqual ( [
{ text : 'Single part 1' } ,
{ inlineData : { mimeType : 'image/gif' , data : 'gif' } } ,
{ text : 'Another single part' } ,
] ) ;
} ) ;
it ( 'should handle a mix of arrays and single parts, including empty arrays and undefined/null parts if they were possible (though PartListUnion typing restricts this)' , ( ) = > {
const list1 : PartListUnion = [ { text : 'A' } ] ;
const list2 : PartListUnion = [ ] ;
const part3 : Part = { text : 'B' } ;
const list4 : PartListUnion = [
{ text : 'C' } ,
{ inlineData : { mimeType : 'text/plain' , data : 'D' } } ,
] ;
const result = mergePartListUnions ( [ list1 , list2 , part3 , list4 ] ) ;
expect ( result ) . toEqual ( [
{ text : 'A' } ,
{ text : 'B' } ,
{ text : 'C' } ,
{ inlineData : { mimeType : 'text/plain' , data : 'D' } } ,
] ) ;
} ) ;
it ( 'should preserve the order of parts from the input arrays' , ( ) = > {
const listA : PartListUnion = [ { text : '1' } , { text : '2' } ] ;
const listB : PartListUnion = [ { text : '3' } ] ;
const listC : PartListUnion = [ { text : '4' } , { text : '5' } ] ;
const result = mergePartListUnions ( [ listA , listB , listC ] ) ;
expect ( result ) . toEqual ( [
{ text : '1' } ,
{ text : '2' } ,
{ text : '3' } ,
{ text : '4' } ,
{ text : '5' } ,
] ) ;
} ) ;
it ( 'should handle cases where some PartListUnion items are single Parts and others are arrays of Parts' , ( ) = > {
const singlePart1 : Part = { text : 'First single' } ;
const arrayPart1 : Part [ ] = [
{ text : 'Array item 1' } ,
{ text : 'Array item 2' } ,
] ;
const singlePart2 : Part = {
inlineData : { mimeType : 'application/json' , data : 'e30=' } ,
} ; // {}
const arrayPart2 : Part [ ] = [ { text : 'Last array item' } ] ;
const result = mergePartListUnions ( [
singlePart1 ,
arrayPart1 ,
singlePart2 ,
arrayPart2 ,
] ) ;
expect ( result ) . toEqual ( [
{ text : 'First single' } ,
{ text : 'Array item 1' } ,
{ text : 'Array item 2' } ,
{ inlineData : { mimeType : 'application/json' , data : 'e30=' } } ,
{ text : 'Last array item' } ,
] ) ;
} ) ;
} ) ;
2025-06-02 01:50:28 -07:00
// --- Tests for useGeminiStream Hook ---
describe ( 'useGeminiStream' , ( ) = > {
let mockAddItem : Mock ;
let mockSetShowHelp : Mock ;
let mockConfig : Config ;
let mockOnDebugMessage : Mock ;
let mockHandleSlashCommand : Mock ;
let mockScheduleToolCalls : Mock ;
let mockCancelAllToolCalls : Mock ;
let mockMarkToolsAsSubmitted : Mock ;
beforeEach ( ( ) = > {
vi . clearAllMocks ( ) ; // Clear mocks before each test
mockAddItem = vi . fn ( ) ;
mockSetShowHelp = vi . fn ( ) ;
2025-06-02 22:30:52 -07:00
// Define the mock for getGeminiClient
const mockGetGeminiClient = vi . fn ( ) . mockImplementation ( ( ) = > {
// MockedGeminiClientClass is defined in the module scope by the previous change.
// It will use the mockStartChat and mockSendMessageStream that are managed within beforeEach.
const clientInstance = new MockedGeminiClientClass ( mockConfig ) ;
return clientInstance ;
} ) ;
2025-06-02 01:50:28 -07:00
mockConfig = {
apiKey : 'test-api-key' ,
model : 'gemini-pro' ,
sandbox : false ,
targetDir : '/test/dir' ,
debugMode : false ,
question : undefined ,
fullContext : false ,
coreTools : [ ] ,
toolDiscoveryCommand : undefined ,
toolCallCommand : undefined ,
mcpServerCommand : undefined ,
mcpServers : undefined ,
userAgent : 'test-agent' ,
userMemory : '' ,
geminiMdFileCount : 0 ,
alwaysSkipModificationConfirmation : false ,
vertexai : false ,
showMemoryUsage : false ,
contextFileName : undefined ,
getToolRegistry : vi.fn (
( ) = > ( { getToolSchemaList : vi.fn ( ( ) = > [ ] ) } ) as any ,
) ,
2025-06-02 22:30:52 -07:00
getGeminiClient : mockGetGeminiClient ,
2025-06-02 01:50:28 -07:00
} as unknown as Config ;
mockOnDebugMessage = vi . fn ( ) ;
mockHandleSlashCommand = vi . fn ( ) . mockReturnValue ( false ) ;
// Mock return value for useReactToolScheduler
mockScheduleToolCalls = vi . fn ( ) ;
mockCancelAllToolCalls = vi . fn ( ) ;
mockMarkToolsAsSubmitted = vi . fn ( ) ;
// Default mock for useReactToolScheduler to prevent toolCalls being undefined initially
mockUseReactToolScheduler . mockReturnValue ( [
[ ] , // Default to empty array for toolCalls
mockScheduleToolCalls ,
mockCancelAllToolCalls ,
mockMarkToolsAsSubmitted ,
] ) ;
// Reset mocks for GeminiClient instance methods (startChat and sendMessageStream)
// The GeminiClient constructor itself is mocked at the module level.
mockStartChat . mockClear ( ) . mockResolvedValue ( {
sendMessageStream : mockSendMessageStream ,
} as unknown as any ) ; // GeminiChat -> any
mockSendMessageStream
. mockClear ( )
. mockReturnValue ( ( async function * ( ) { } ) ( ) ) ;
} ) ;
const renderTestHook = ( initialToolCalls : TrackedToolCall [ ] = [ ] ) = > {
mockUseReactToolScheduler . mockReturnValue ( [
initialToolCalls ,
mockScheduleToolCalls ,
mockCancelAllToolCalls ,
mockMarkToolsAsSubmitted ,
] ) ;
const { result , rerender } = renderHook ( ( ) = >
useGeminiStream (
2025-06-05 21:33:24 +00:00
mockConfig . getGeminiClient ( ) ,
2025-06-02 01:50:28 -07:00
mockAddItem as unknown as UseHistoryManagerReturn [ 'addItem' ] ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false , // shellModeActive
) ,
) ;
return {
result ,
rerender ,
mockMarkToolsAsSubmitted ,
mockSendMessageStream ,
// mockFilter removed
} ;
} ;
it ( 'should not submit tool responses if not all tool calls are completed' , ( ) = > {
const toolCalls : TrackedToolCall [ ] = [
{
request : { callId : 'call1' , name : 'tool1' , args : { } } ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'call1' ,
responseParts : [ { text : 'tool 1 response' } ] ,
error : undefined ,
resultDisplay : 'Tool 1 success display' ,
} ,
tool : {
name : 'tool1' ,
description : 'desc1' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
endTime : Date.now ( ) ,
} as TrackedCompletedToolCall ,
{
request : { callId : 'call2' , name : 'tool2' , args : { } } ,
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool2' ,
description : 'desc2' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
liveOutput : '...' ,
} as TrackedExecutingToolCall ,
] ;
const { mockMarkToolsAsSubmitted , mockSendMessageStream } =
renderTestHook ( toolCalls ) ;
// Effect for submitting tool responses depends on toolCalls and isResponding
// isResponding is initially false, so the effect should run.
expect ( mockMarkToolsAsSubmitted ) . not . toHaveBeenCalled ( ) ;
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ; // submitQuery uses this
} ) ;
it ( 'should submit tool responses when all tool calls are completed and ready' , async ( ) = > {
const toolCall1ResponseParts : PartListUnion = [
{ text : 'tool 1 final response' } ,
] ;
const toolCall2ResponseParts : PartListUnion = [
{ text : 'tool 2 final response' } ,
] ;
// Simplified toolCalls to ensure the filter logic is the focus
const simplifiedToolCalls : TrackedToolCall [ ] = [
{
request : { callId : 'call1' , name : 'tool1' , args : { } } ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'call1' ,
responseParts : toolCall1ResponseParts ,
error : undefined ,
resultDisplay : 'Tool 1 success display' ,
} ,
tool : {
name : 'tool1' ,
description : 'desc' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
endTime : Date.now ( ) ,
} as TrackedCompletedToolCall ,
{
request : { callId : 'call2' , name : 'tool2' , args : { } } ,
status : 'cancelled' ,
responseSubmittedToGemini : false ,
response : {
callId : 'call2' ,
responseParts : toolCall2ResponseParts ,
error : undefined ,
resultDisplay : 'Tool 2 cancelled display' ,
} ,
tool : {
name : 'tool2' ,
description : 'desc' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
endTime : Date.now ( ) ,
reason : 'test cancellation' ,
} as TrackedCancelledToolCall ,
] ;
2025-06-03 02:10:54 +00:00
const hookResult = await act ( async ( ) = >
renderTestHook ( simplifiedToolCalls ) ,
) ;
2025-06-02 01:50:28 -07:00
const {
mockMarkToolsAsSubmitted ,
mockSendMessageStream : localMockSendMessageStream ,
} = hookResult ! ;
// It seems the initial render + effect run should be enough.
// If rerender was for a specific state change, it might still be needed.
// For now, let's test if the initial effect run (covered by the first act) is sufficient.
// If not, we can add back: await act(async () => { rerender({}); });
expect ( mockMarkToolsAsSubmitted ) . toHaveBeenCalledWith ( [ 'call1' , 'call2' ] ) ;
await waitFor ( ( ) = > {
expect ( localMockSendMessageStream ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
const expectedMergedResponse = mergePartListUnions ( [
toolCall1ResponseParts ,
toolCall2ResponseParts ,
] ) ;
expect ( localMockSendMessageStream ) . toHaveBeenCalledWith (
expectedMergedResponse ,
2025-06-03 02:10:54 +00:00
expect . any ( AbortSignal ) ,
2025-06-02 01:50:28 -07:00
) ;
} ) ;
} ) ;