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' ;
2025-06-20 23:01:44 -04:00
import { useInput } from 'ink' ;
2025-06-02 01:50:28 -07:00
import {
useReactToolScheduler ,
TrackedToolCall ,
TrackedCompletedToolCall ,
TrackedExecutingToolCall ,
TrackedCancelledToolCall ,
} from './useReactToolScheduler.js' ;
2025-06-25 05:41:11 -07:00
import { Config , EditorType , AuthType } from '@google/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-06-20 23:01:44 -04:00
import { HistoryItem , MessageType , StreamingState } from '../types.js' ;
2025-06-08 15:42:49 -07:00
import { Dispatch , SetStateAction } from 'react' ;
2025-06-12 02:21:54 +01:00
import { LoadedSettings } from '../../config/settings.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-08 11:14:45 -07:00
this . addHistory = vi . fn ( ) ;
2025-06-02 22:30:52 -07:00
} ) ,
) ;
2025-06-22 09:26:48 -05:00
const MockedUserPromptEvent = vi . hoisted ( ( ) = >
vi . fn ( ) . mockImplementation ( ( ) = > { } ) ,
) ;
2025-06-25 05:41:11 -07:00
vi . mock ( '@google/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 {
2025-06-11 15:33:09 -04:00
. . . actualCoreModule ,
GitService : vi.fn ( ) ,
GeminiClient : MockedGeminiClientClass ,
2025-06-22 09:26:48 -05:00
UserPromptEvent : MockedUserPromptEvent ,
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 ) ,
} ) ,
} ) ) ;
2025-06-09 20:25:37 -04:00
const mockStartNewTurn = vi . fn ( ) ;
const mockAddUsage = vi . fn ( ) ;
vi . mock ( '../contexts/SessionContext.js' , ( ) = > ( {
useSessionStats : vi.fn ( ( ) = > ( {
startNewTurn : mockStartNewTurn ,
addUsage : mockAddUsage ,
} ) ) ,
} ) ) ;
2025-06-02 01:50:28 -07:00
vi . mock ( './slashCommandProcessor.js' , ( ) = > ( {
handleSlashCommand : vi.fn ( ) . mockReturnValue ( false ) ,
} ) ) ;
2025-06-23 23:43:00 -04:00
const mockParseAndFormatApiError = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
vi . mock ( '../utils/errorParsing.js' , ( ) = > ( {
parseAndFormatApiError : mockParseAndFormatApiError ,
} ) ) ;
2025-06-02 01:50:28 -07:00
// --- 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-11 15:33:09 -04:00
getProjectRoot : vi.fn ( ( ) = > '/test/dir' ) ,
2025-06-20 00:39:15 -04:00
getCheckpointingEnabled : vi.fn ( ( ) = > false ) ,
2025-06-02 22:30:52 -07:00
getGeminiClient : mockGetGeminiClient ,
2025-06-23 17:19:40 -04:00
getUsageStatisticsEnabled : ( ) = > true ,
2025-06-23 18:05:02 -04:00
getDebugMode : ( ) = > false ,
2025-06-08 11:14:45 -07:00
addHistory : vi.fn ( ) ,
2025-06-02 01:50:28 -07:00
} as unknown as Config ;
mockOnDebugMessage = vi . fn ( ) ;
2025-06-11 15:33:09 -04:00
mockHandleSlashCommand = vi . fn ( ) . mockResolvedValue ( false ) ;
2025-06-02 01:50:28 -07:00
// 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 * ( ) { } ) ( ) ) ;
} ) ;
2025-06-12 02:21:54 +01:00
const mockLoadedSettings : LoadedSettings = {
merged : { preferredEditor : 'vscode' } ,
user : { path : '/user/settings.json' , settings : { } } ,
workspace : { path : '/workspace/.gemini/settings.json' , settings : { } } ,
errors : [ ] ,
forScope : vi.fn ( ) ,
setValue : vi.fn ( ) ,
} as unknown as LoadedSettings ;
2025-06-08 11:14:45 -07:00
const renderTestHook = (
initialToolCalls : TrackedToolCall [ ] = [ ] ,
geminiClient? : any ,
) = > {
2025-06-19 18:25:23 -07:00
let currentToolCalls = initialToolCalls ;
const setToolCalls = ( newToolCalls : TrackedToolCall [ ] ) = > {
currentToolCalls = newToolCalls ;
} ;
mockUseReactToolScheduler . mockImplementation ( ( ) = > [
currentToolCalls ,
2025-06-02 01:50:28 -07:00
mockScheduleToolCalls ,
mockCancelAllToolCalls ,
mockMarkToolsAsSubmitted ,
] ) ;
2025-06-08 11:14:45 -07:00
const client = geminiClient || mockConfig . getGeminiClient ( ) ;
2025-06-08 15:42:49 -07:00
const { result , rerender } = renderHook (
( props : {
client : any ;
2025-06-11 15:33:09 -04:00
history : HistoryItem [ ] ;
2025-06-08 15:42:49 -07:00
addItem : UseHistoryManagerReturn [ 'addItem' ] ;
setShowHelp : Dispatch < SetStateAction < boolean > > ;
config : Config ;
onDebugMessage : ( message : string ) = > void ;
handleSlashCommand : (
2025-06-11 15:33:09 -04:00
cmd : PartListUnion ,
) = > Promise <
2025-06-08 15:42:49 -07:00
| import ( './slashCommandProcessor.js' ) . SlashCommandActionReturn
2025-06-11 15:33:09 -04:00
| boolean
> ;
2025-06-08 15:42:49 -07:00
shellModeActive : boolean ;
2025-06-12 02:21:54 +01:00
loadedSettings : LoadedSettings ;
2025-06-19 18:25:23 -07:00
toolCalls? : TrackedToolCall [ ] ; // Allow passing updated toolCalls
} ) = > {
// Update the mock's return value if new toolCalls are passed in props
if ( props . toolCalls ) {
setToolCalls ( props . toolCalls ) ;
}
return useGeminiStream (
2025-06-08 15:42:49 -07:00
props . client ,
2025-06-11 15:33:09 -04:00
props . history ,
2025-06-08 15:42:49 -07:00
props . addItem ,
props . setShowHelp ,
props . config ,
props . onDebugMessage ,
props . handleSlashCommand ,
props . shellModeActive ,
2025-06-12 02:21:54 +01:00
( ) = > 'vscode' as EditorType ,
2025-06-19 16:52:22 -07:00
( ) = > { } ,
2025-06-22 01:35:36 -04:00
( ) = > Promise . resolve ( ) ,
2025-06-19 18:25:23 -07:00
) ;
} ,
2025-06-08 15:42:49 -07:00
{
initialProps : {
client ,
2025-06-11 15:33:09 -04:00
history : [ ] ,
2025-06-08 15:42:49 -07:00
addItem : mockAddItem as unknown as UseHistoryManagerReturn [ 'addItem' ] ,
setShowHelp : mockSetShowHelp ,
config : mockConfig ,
onDebugMessage : mockOnDebugMessage ,
2025-06-11 15:33:09 -04:00
handleSlashCommand : mockHandleSlashCommand as unknown as (
cmd : PartListUnion ,
) = > Promise <
| import ( './slashCommandProcessor.js' ) . SlashCommandActionReturn
| boolean
> ,
2025-06-08 15:42:49 -07:00
shellModeActive : false ,
2025-06-12 02:21:54 +01:00
loadedSettings : mockLoadedSettings ,
2025-06-22 01:35:36 -04:00
toolCalls : initialToolCalls ,
2025-06-08 15:42:49 -07:00
} ,
} ,
2025-06-02 01:50:28 -07:00
) ;
return {
result ,
rerender ,
mockMarkToolsAsSubmitted ,
mockSendMessageStream ,
2025-06-08 11:14:45 -07:00
client ,
2025-06-02 01:50:28 -07:00
} ;
} ;
it ( 'should not submit tool responses if not all tool calls are completed' , ( ) = > {
const toolCalls : TrackedToolCall [ ] = [
{
2025-06-22 01:35:36 -04:00
request : {
callId : 'call1' ,
name : 'tool1' ,
args : { } ,
isClientInitiated : false ,
} ,
2025-06-02 01:50:28 -07:00
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' } ,
] ;
2025-06-22 01:35:36 -04:00
const completedToolCalls : TrackedToolCall [ ] = [
2025-06-02 01:50:28 -07:00
{
2025-06-22 01:35:36 -04:00
request : {
2025-06-02 01:50:28 -07:00
callId : 'call1' ,
name : 'tool1' ,
2025-06-22 01:35:36 -04:00
args : { } ,
isClientInitiated : false ,
} ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : { callId : 'call1' , responseParts : toolCall1ResponseParts } ,
2025-06-02 01:50:28 -07:00
} as TrackedCompletedToolCall ,
{
2025-06-22 01:35:36 -04:00
request : {
2025-06-02 01:50:28 -07:00
callId : 'call2' ,
name : 'tool2' ,
2025-06-22 01:35:36 -04:00
args : { } ,
isClientInitiated : false ,
} ,
status : 'error' ,
responseSubmittedToGemini : false ,
response : { callId : 'call2' , responseParts : toolCall2ResponseParts } ,
} as TrackedCompletedToolCall , // Treat error as a form of completion for submission
2025-06-02 01:50:28 -07:00
] ;
2025-06-27 16:39:54 -07:00
// Capture the onComplete callback
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted ] ;
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
) ,
) ;
2025-06-02 01:50:28 -07:00
2025-06-27 16:39:54 -07:00
// Trigger the onComplete callback with completed tools
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( completedToolCalls ) ;
}
2025-06-08 15:42:49 -07:00
} ) ;
2025-06-02 01:50:28 -07:00
await waitFor ( ( ) = > {
2025-06-22 01:35:36 -04:00
expect ( mockMarkToolsAsSubmitted ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockSendMessageStream ) . toHaveBeenCalledTimes ( 1 ) ;
2025-06-02 01:50:28 -07:00
} ) ;
const expectedMergedResponse = mergePartListUnions ( [
toolCall1ResponseParts ,
toolCall2ResponseParts ,
] ) ;
2025-06-22 01:35:36 -04:00
expect ( mockSendMessageStream ) . toHaveBeenCalledWith (
2025-06-02 01:50:28 -07:00
expectedMergedResponse ,
2025-06-03 02:10:54 +00:00
expect . any ( AbortSignal ) ,
2025-06-02 01:50:28 -07:00
) ;
} ) ;
2025-06-08 11:14:45 -07:00
it ( 'should handle all tool calls being cancelled' , async ( ) = > {
2025-06-22 01:35:36 -04:00
const cancelledToolCalls : TrackedToolCall [ ] = [
2025-06-08 11:14:45 -07:00
{
2025-06-22 01:35:36 -04:00
request : {
2025-06-08 11:14:45 -07:00
callId : '1' ,
2025-06-22 01:35:36 -04:00
name : 'testTool' ,
args : { } ,
isClientInitiated : false ,
2025-06-08 11:14:45 -07:00
} ,
2025-06-22 01:35:36 -04:00
status : 'cancelled' ,
response : { callId : '1' , responseParts : [ { text : 'cancelled' } ] } ,
2025-06-08 11:14:45 -07:00
responseSubmittedToGemini : false ,
2025-06-22 01:35:36 -04:00
} as TrackedCancelledToolCall ,
2025-06-08 11:14:45 -07:00
] ;
const client = new MockedGeminiClientClass ( mockConfig ) ;
2025-06-22 01:35:36 -04:00
2025-06-27 16:39:54 -07:00
// Capture the onComplete callback
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted ] ;
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
client ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
) ,
2025-06-08 11:14:45 -07:00
) ;
2025-06-27 16:39:54 -07:00
// Trigger the onComplete callback with cancelled tools
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( cancelledToolCalls ) ;
}
2025-06-08 11:14:45 -07:00
} ) ;
await waitFor ( ( ) = > {
2025-06-22 01:35:36 -04:00
expect ( mockMarkToolsAsSubmitted ) . toHaveBeenCalledWith ( [ '1' ] ) ;
2025-06-08 11:14:45 -07:00
expect ( client . addHistory ) . toHaveBeenCalledWith ( {
role : 'user' ,
parts : [ { text : 'cancelled' } ] ,
} ) ;
2025-06-22 01:35:36 -04:00
// Ensure we do NOT call back to the API
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ;
2025-06-08 11:14:45 -07:00
} ) ;
} ) ;
2025-06-09 20:25:37 -04:00
2025-06-19 18:25:23 -07:00
it ( 'should not flicker streaming state to Idle between tool completion and submission' , async ( ) = > {
const toolCallResponseParts : PartListUnion = [
{ text : 'tool 1 final response' } ,
] ;
const initialToolCalls : TrackedToolCall [ ] = [
{
2025-06-27 16:39:54 -07:00
request : {
callId : 'call1' ,
name : 'tool1' ,
args : { } ,
isClientInitiated : false ,
} ,
2025-06-19 18:25:23 -07:00
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool1' ,
description : 'desc' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
} as TrackedExecutingToolCall ,
] ;
const completedToolCalls : TrackedToolCall [ ] = [
{
. . . ( initialToolCalls [ 0 ] as TrackedExecutingToolCall ) ,
status : 'success' ,
response : {
callId : 'call1' ,
responseParts : toolCallResponseParts ,
error : undefined ,
resultDisplay : 'Tool 1 success display' ,
} ,
endTime : Date.now ( ) ,
} as TrackedCompletedToolCall ,
] ;
2025-06-27 16:39:54 -07:00
// Capture the onComplete callback
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
let currentToolCalls = initialToolCalls ;
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [
currentToolCalls ,
mockScheduleToolCalls ,
mockMarkToolsAsSubmitted ,
] ;
} ) ;
const { result , rerender } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
) ,
) ;
2025-06-19 18:25:23 -07:00
// 1. Initial state should be Responding because a tool is executing.
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
2025-06-27 16:39:54 -07:00
// 2. Update the tool calls to completed state and rerender
currentToolCalls = completedToolCalls ;
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [
completedToolCalls ,
mockScheduleToolCalls ,
mockMarkToolsAsSubmitted ,
] ;
} ) ;
2025-06-19 18:25:23 -07:00
act ( ( ) = > {
2025-06-27 16:39:54 -07:00
rerender ( ) ;
2025-06-19 18:25:23 -07:00
} ) ;
// 3. The state should *still* be Responding, not Idle.
// This is because the completed tool's response has not been submitted yet.
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
2025-06-27 16:39:54 -07:00
// 4. Trigger the onComplete callback to simulate tool completion
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( completedToolCalls ) ;
}
} ) ;
// 5. Wait for submitQuery to be called
2025-06-19 18:25:23 -07:00
await waitFor ( ( ) = > {
expect ( mockSendMessageStream ) . toHaveBeenCalledWith (
toolCallResponseParts ,
expect . any ( AbortSignal ) ,
) ;
} ) ;
2025-06-27 16:39:54 -07:00
// 6. After submission, the state should remain Responding until the stream completes.
2025-06-19 18:25:23 -07:00
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
} ) ;
2025-06-20 23:01:44 -04:00
describe ( 'User Cancellation' , ( ) = > {
let useInputCallback : ( input : string , key : any ) = > void ;
const mockUseInput = useInput as Mock ;
beforeEach ( ( ) = > {
// Capture the callback passed to useInput
mockUseInput . mockImplementation ( ( callback ) = > {
useInputCallback = callback ;
} ) ;
} ) ;
const simulateEscapeKeyPress = ( ) = > {
act ( ( ) = > {
useInputCallback ( '' , { escape : true } ) ;
} ) ;
} ;
it ( 'should cancel an in-progress stream when escape is pressed' , async ( ) = > {
const mockStream = ( async function * ( ) {
yield { type : 'content' , value : 'Part 1' } ;
// Keep the stream open
await new Promise ( ( ) = > { } ) ;
} ) ( ) ;
mockSendMessageStream . mockReturnValue ( mockStream ) ;
const { result } = renderTestHook ( ) ;
// Start a query
await act ( async ( ) = > {
result . current . submitQuery ( 'test query' ) ;
} ) ;
// Wait for the first part of the response
await waitFor ( ( ) = > {
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
} ) ;
// Simulate escape key press
simulateEscapeKeyPress ( ) ;
// Verify cancellation message is added
await waitFor ( ( ) = > {
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : MessageType . INFO ,
text : 'Request cancelled.' ,
} ,
expect . any ( Number ) ,
) ;
} ) ;
// Verify state is reset
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
} ) ;
it ( 'should not do anything if escape is pressed when not responding' , ( ) = > {
const { result } = renderTestHook ( ) ;
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
// Simulate escape key press
simulateEscapeKeyPress ( ) ;
// No change should happen, no cancellation message
expect ( mockAddItem ) . not . toHaveBeenCalledWith (
expect . objectContaining ( {
text : 'Request cancelled.' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
it ( 'should prevent further processing after cancellation' , async ( ) = > {
let continueStream : ( ) = > void ;
const streamPromise = new Promise < void > ( ( resolve ) = > {
continueStream = resolve ;
} ) ;
const mockStream = ( async function * ( ) {
yield { type : 'content' , value : 'Initial' } ;
await streamPromise ; // Wait until we manually continue
yield { type : 'content' , value : ' Canceled' } ;
} ) ( ) ;
mockSendMessageStream . mockReturnValue ( mockStream ) ;
const { result } = renderTestHook ( ) ;
await act ( async ( ) = > {
result . current . submitQuery ( 'long running query' ) ;
} ) ;
await waitFor ( ( ) = > {
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
} ) ;
// Cancel the request
simulateEscapeKeyPress ( ) ;
// Allow the stream to continue
act ( ( ) = > {
continueStream ( ) ;
} ) ;
// Wait a bit to see if the second part is processed
await new Promise ( ( resolve ) = > setTimeout ( resolve , 50 ) ) ;
// The text should not have been updated with " Canceled"
const lastCall = mockAddItem . mock . calls . find (
( call ) = > call [ 0 ] . type === 'gemini' ,
) ;
expect ( lastCall ? . [ 0 ] . text ) . toBe ( 'Initial' ) ;
// The final state should be idle after cancellation
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
} ) ;
it ( 'should not cancel if a tool call is in progress (not just responding)' , async ( ) = > {
const toolCalls : TrackedToolCall [ ] = [
{
request : { callId : 'call1' , name : 'tool1' , args : { } } ,
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool1' ,
description : 'desc1' ,
getDescription : vi.fn ( ) ,
} as any ,
startTime : Date.now ( ) ,
liveOutput : '...' ,
} as TrackedExecutingToolCall ,
] ;
const abortSpy = vi . spyOn ( AbortController . prototype , 'abort' ) ;
const { result } = renderTestHook ( toolCalls ) ;
// State is `Responding` because a tool is running
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
// Try to cancel
simulateEscapeKeyPress ( ) ;
// Nothing should happen because the state is not `Responding`
expect ( abortSpy ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
2025-06-22 01:35:36 -04:00
describe ( 'Client-Initiated Tool Calls' , ( ) = > {
it ( 'should execute a client-initiated tool without sending a response to Gemini' , async ( ) = > {
const clientToolRequest = {
shouldScheduleTool : true ,
toolName : 'save_memory' ,
toolArgs : { fact : 'test fact' } ,
} ;
mockHandleSlashCommand . mockResolvedValue ( clientToolRequest ) ;
const completedToolCall : TrackedCompletedToolCall = {
request : {
callId : 'client-call-1' ,
name : clientToolRequest.toolName ,
args : clientToolRequest.toolArgs ,
isClientInitiated : true ,
} ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'client-call-1' ,
responseParts : [ { text : 'Memory saved' } ] ,
resultDisplay : 'Success: Memory saved' ,
error : undefined ,
} ,
tool : {
name : clientToolRequest.toolName ,
description : 'Saves memory' ,
getDescription : vi.fn ( ) ,
} as any ,
} ;
2025-06-27 16:39:54 -07:00
// Capture the onComplete callback
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
2025-06-22 01:35:36 -04:00
2025-06-27 16:39:54 -07:00
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted ] ;
} ) ;
const { result } = renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
) ,
) ;
// --- User runs the slash command ---
await act ( async ( ) = > {
await result . current . submitQuery ( '/memory add "test fact"' ) ;
} ) ;
2025-06-27 16:39:54 -07:00
// Trigger the onComplete callback with the completed client-initiated tool
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( [ completedToolCall ] ) ;
}
2025-06-22 01:35:36 -04:00
} ) ;
// --- Assert the outcome ---
await waitFor ( ( ) = > {
// The tool should be marked as submitted locally
expect ( mockMarkToolsAsSubmitted ) . toHaveBeenCalledWith ( [
'client-call-1' ,
] ) ;
// Crucially, no message should be sent to the Gemini API
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
} ) ;
describe ( 'Memory Refresh on save_memory' , ( ) = > {
it ( 'should call performMemoryRefresh when a save_memory tool call completes successfully' , async ( ) = > {
const mockPerformMemoryRefresh = vi . fn ( ) ;
const completedToolCall : TrackedCompletedToolCall = {
request : {
callId : 'save-mem-call-1' ,
name : 'save_memory' ,
args : { fact : 'test' } ,
isClientInitiated : true ,
} ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'save-mem-call-1' ,
responseParts : [ { text : 'Memory saved' } ] ,
resultDisplay : 'Success: Memory saved' ,
error : undefined ,
} ,
tool : {
name : 'save_memory' ,
description : 'Saves memory' ,
getDescription : vi.fn ( ) ,
} as any ,
} ;
2025-06-27 16:39:54 -07:00
// Capture the onComplete callback
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
2025-06-22 01:35:36 -04:00
2025-06-27 16:39:54 -07:00
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted ] ;
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
mockConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
mockPerformMemoryRefresh ,
) ,
) ;
2025-06-27 16:39:54 -07:00
// Trigger the onComplete callback with the completed save_memory tool
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( [ completedToolCall ] ) ;
}
2025-06-22 01:35:36 -04:00
} ) ;
await waitFor ( ( ) = > {
expect ( mockPerformMemoryRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
} ) ;
} ) ;
2025-06-23 23:43:00 -04:00
describe ( 'Error Handling' , ( ) = > {
it ( 'should call parseAndFormatApiError with the correct authType on stream initialization failure' , async ( ) = > {
// 1. Setup
const mockError = new Error ( 'Rate limit exceeded' ) ;
2025-06-25 15:38:18 -07:00
const mockAuthType = AuthType . LOGIN_WITH_GOOGLE_PERSONAL ;
2025-06-23 23:43:00 -04:00
mockParseAndFormatApiError . mockClear ( ) ;
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield { type : 'content' , value : '' } ;
throw mockError ;
} ) ( ) ,
) ;
const testConfig = {
. . . mockConfig ,
getContentGeneratorConfig : vi.fn ( ( ) = > ( {
authType : mockAuthType ,
} ) ) ,
} as unknown as Config ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( testConfig ) ,
[ ] ,
mockAddItem ,
mockSetShowHelp ,
testConfig ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
) ,
) ;
// 2. Action
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
// 3. Assertion
await waitFor ( ( ) = > {
expect ( mockParseAndFormatApiError ) . toHaveBeenCalledWith (
'Rate limit exceeded' ,
mockAuthType ,
) ;
} ) ;
} ) ;
} ) ;
2025-06-02 01:50:28 -07:00
} ) ;