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 */
2025-08-26 00:04:53 +02:00
import type { Mock , MockInstance } from 'vitest' ;
import { describe , it , expect , vi , beforeEach } from 'vitest' ;
2025-10-28 10:32:15 -07:00
import { act } from 'react' ;
import { renderHook } from '../../test-utils/render.js' ;
2025-10-30 11:50:26 -07:00
import { waitFor } from '../../test-utils/async.js' ;
2025-08-22 14:12:05 -07:00
import { useGeminiStream } from './useGeminiStream.js' ;
2025-08-12 14:05:49 -07:00
import { useKeypress } from './useKeypress.js' ;
2025-08-20 15:51:31 -04:00
import * as atCommandProcessor from './atCommandProcessor.js' ;
2025-08-26 00:04:53 +02:00
import type {
2025-06-02 01:50:28 -07:00
TrackedToolCall ,
TrackedCompletedToolCall ,
TrackedExecutingToolCall ,
TrackedCancelledToolCall ,
2025-09-14 20:20:21 -07:00
TrackedWaitingToolCall ,
2025-06-02 01:50:28 -07:00
} from './useReactToolScheduler.js' ;
2025-08-26 00:04:53 +02:00
import { useReactToolScheduler } from './useReactToolScheduler.js' ;
import type {
2025-07-22 06:57:11 +09:00
Config ,
EditorType ,
2025-08-20 15:51:31 -04:00
GeminiClient ,
2025-08-06 10:50:02 -07:00
AnyToolInvocation ,
2025-07-22 06:57:11 +09:00
} from '@google/gemini-cli-core' ;
2025-07-07 16:45:44 -04:00
import {
2025-08-26 00:04:53 +02:00
ApprovalMode ,
AuthType ,
GeminiEventType as ServerGeminiEventType ,
ToolErrorType ,
2025-09-14 20:20:21 -07:00
ToolConfirmationOutcome ,
2025-10-09 10:22:26 -07:00
tokenLimit ,
2025-10-28 19:05:48 +00:00
debugLogger ,
2025-08-26 00:04:53 +02:00
} from '@google/gemini-cli-core' ;
import type { Part , PartListUnion } from '@google/genai' ;
import type { UseHistoryManagerReturn } from './useHistoryManager.js' ;
2025-10-27 09:59:08 -07:00
import type { SlashCommandProcessorResult } from '../types.js' ;
2025-08-26 00:04:53 +02:00
import { MessageType , StreamingState } from '../types.js' ;
import type { 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-10-06 13:34:00 -06:00
this . getChat = vi . fn ( ) . mockReturnValue ( {
recordCompletedToolCalls : vi.fn ( ) ,
} ) ;
2025-09-02 23:29:07 -06:00
this . getChatRecordingService = vi . fn ( ) . mockReturnValue ( {
recordThought : vi.fn ( ) ,
initialize : vi.fn ( ) ,
recordMessage : vi.fn ( ) ,
recordMessageTokens : vi.fn ( ) ,
recordToolCalls : vi.fn ( ) ,
getConversationFile : 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-08-13 17:57:11 +00:00
const mockParseAndFormatApiError = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-06-22 09:26:48 -05:00
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-08-13 17:57:11 +00:00
parseAndFormatApiError : mockParseAndFormatApiError ,
2025-10-09 10:22:26 -07:00
tokenLimit : vi.fn ( ) . mockReturnValue ( 100 ) , // Mock tokenLimit
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-08-12 14:05:49 -07:00
vi . mock ( './useKeypress.js' , ( ) = > ( {
useKeypress : vi.fn ( ) ,
} ) ) ;
2025-06-02 01:50:28 -07:00
vi . mock ( './shellCommandProcessor.js' , ( ) = > ( {
useShellCommandProcessor : vi.fn ( ) . mockReturnValue ( {
handleShellCommand : vi.fn ( ) ,
} ) ,
} ) ) ;
2025-08-20 15:51:31 -04:00
vi . mock ( './atCommandProcessor.js' ) ;
2025-06-02 01:50:28 -07:00
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 ;
} ) ;
2025-09-17 15:37:13 -07:00
return [ val , ref , setVal ] ;
2025-06-02 01:50:28 -07:00
} ) ,
} ) ) ;
vi . mock ( './useLogger.js' , ( ) = > ( {
useLogger : vi.fn ( ) . mockReturnValue ( {
logMessage : vi.fn ( ) . mockResolvedValue ( undefined ) ,
} ) ,
} ) ) ;
2025-07-10 00:19:30 +05:30
const mockStartNewPrompt = vi . fn ( ) ;
2025-06-09 20:25:37 -04:00
const mockAddUsage = vi . fn ( ) ;
vi . mock ( '../contexts/SessionContext.js' , ( ) = > ( {
useSessionStats : vi.fn ( ( ) = > ( {
2025-07-10 00:19:30 +05:30
startNewPrompt : mockStartNewPrompt ,
2025-06-09 20:25:37 -04:00
addUsage : mockAddUsage ,
2025-07-10 00:19:30 +05:30
getPromptCount : vi.fn ( ( ) = > 5 ) ,
2025-06-09 20:25:37 -04:00
} ) ) ,
} ) ) ;
2025-06-02 01:50:28 -07:00
vi . mock ( './slashCommandProcessor.js' , ( ) = > ( {
handleSlashCommand : vi.fn ( ) . mockReturnValue ( false ) ,
} ) ) ;
2025-11-11 07:50:11 -08:00
vi . mock ( './useAlternateBuffer.js' , ( ) = > ( {
useAlternateBuffer : vi.fn ( ( ) = > false ) ,
} ) ) ;
2025-06-02 01:50:28 -07:00
// --- END MOCKS ---
// --- Tests for useGeminiStream Hook ---
describe ( 'useGeminiStream' , ( ) = > {
let mockAddItem : Mock ;
let mockConfig : Config ;
let mockOnDebugMessage : Mock ;
let mockHandleSlashCommand : Mock ;
let mockScheduleToolCalls : Mock ;
let mockCancelAllToolCalls : Mock ;
let mockMarkToolsAsSubmitted : Mock ;
2025-08-20 15:51:31 -04:00
let handleAtCommandSpy : MockInstance ;
2025-06-02 01:50:28 -07:00
beforeEach ( ( ) = > {
vi . clearAllMocks ( ) ; // Clear mocks before each test
mockAddItem = 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-07-11 22:17:46 +05:30
const contentGeneratorConfig = {
model : 'test-model' ,
apiKey : 'test-key' ,
vertexai : false ,
authType : AuthType.USE_GEMINI ,
} ;
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 ,
2025-10-16 12:09:21 -07:00
2025-06-02 01:50:28 -07:00
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-08-25 16:06:47 -04:00
getApprovalMode : ( ) = > ApprovalMode . DEFAULT ,
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-07-10 00:19:30 +05:30
getSessionId() {
return 'test-session-id' ;
} ,
2025-07-09 13:55:56 -04:00
setQuotaErrorOccurred : vi.fn ( ) ,
getQuotaErrorOccurred : vi.fn ( ( ) = > false ) ,
2025-07-28 23:27:33 +05:30
getModel : vi.fn ( ( ) = > 'gemini-2.5-pro' ) ,
2025-07-11 22:17:46 +05:30
getContentGeneratorConfig : vi
. fn ( )
. mockReturnValue ( contentGeneratorConfig ) ,
2025-09-03 04:58:47 -05:00
getUseSmartEdit : ( ) = > false ,
2025-11-11 02:03:32 -08:00
isInteractive : ( ) = > false ,
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 ,
mockMarkToolsAsSubmitted ,
2025-10-27 09:59:08 -07:00
vi . fn ( ) , // setToolCallsForDisplay
mockCancelAllToolCalls ,
2025-06-02 01:50:28 -07:00
] ) ;
// 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-08-20 15:51:31 -04:00
handleAtCommandSpy = vi . spyOn ( atCommandProcessor , 'handleAtCommand' ) ;
2025-06-02 01:50:28 -07:00
} ) ;
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 ,
) = > {
const client = geminiClient || mockConfig . getGeminiClient ( ) ;
2025-10-27 09:59:08 -07:00
const initialProps = {
client ,
history : [ ] ,
addItem : mockAddItem as unknown as UseHistoryManagerReturn [ 'addItem' ] ,
config : mockConfig ,
onDebugMessage : mockOnDebugMessage ,
handleSlashCommand : mockHandleSlashCommand as unknown as (
cmd : PartListUnion ,
) = > Promise < SlashCommandProcessorResult | false > ,
shellModeActive : false ,
loadedSettings : mockLoadedSettings ,
toolCalls : initialToolCalls ,
} ;
2025-06-08 15:42:49 -07:00
const { result , rerender } = renderHook (
2025-10-27 09:59:08 -07:00
( props : typeof initialProps ) = > {
// This mock needs to be stateful. When setToolCallsForDisplay is called,
// it should trigger a rerender with the new state.
const mockSetToolCallsForDisplay = vi . fn ( ( updater ) = > {
const newToolCalls =
typeof updater === 'function' ? updater ( props . toolCalls ) : updater ;
rerender ( { . . . props , toolCalls : newToolCalls } ) ;
} ) ;
// Create a stateful mock for cancellation that updates the toolCalls state.
const statefulCancelAllToolCalls = vi . fn ( ( . . . args ) = > {
// Call the original spy so `toHaveBeenCalled` checks still work.
mockCancelAllToolCalls ( . . . args ) ;
const newToolCalls = props . toolCalls . map ( ( tc ) = > {
// Only cancel tools that are in a cancellable state.
if (
tc . status === 'awaiting_approval' ||
tc . status === 'executing' ||
tc . status === 'scheduled' ||
tc . status === 'validating'
) {
// A real cancelled tool call has a response object.
// We need to simulate this to avoid type errors downstream.
return {
. . . tc ,
status : 'cancelled' ,
response : {
callId : tc.request.callId ,
responseParts : [ ] ,
resultDisplay : 'Request cancelled.' ,
} ,
responseSubmittedToGemini : true , // Mark as "processed"
} as any as TrackedCancelledToolCall ;
}
return tc ;
} ) ;
rerender ( { . . . props , toolCalls : newToolCalls } ) ;
} ) ;
mockUseReactToolScheduler . mockImplementation ( ( ) = > [
props . toolCalls ,
mockScheduleToolCalls ,
mockMarkToolsAsSubmitted ,
mockSetToolCallsForDisplay ,
statefulCancelAllToolCalls , // Use the stateful mock
] ) ;
2025-06-19 18:25:23 -07:00
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 . config ,
2025-08-28 16:42:54 -07:00
props . loadedSettings ,
2025-06-08 15:42:49 -07:00
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-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-19 18:25:23 -07:00
) ;
} ,
2025-06-08 15:42:49 -07:00
{
2025-10-27 09:59:08 -07:00
initialProps ,
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
} ;
} ;
2025-11-03 23:40:57 +05:30
// Helper to create mock tool calls - reduces boilerplate
const createMockToolCall = (
toolName : string ,
callId : string ,
confirmationType : 'edit' | 'info' ,
mockOnConfirm : Mock ,
status : TrackedToolCall [ 'status' ] = 'awaiting_approval' ,
) : TrackedWaitingToolCall = > ( {
request : {
callId ,
name : toolName ,
args : { } ,
isClientInitiated : false ,
prompt_id : 'prompt-id-1' ,
} ,
status : status as 'awaiting_approval' ,
responseSubmittedToGemini : false ,
confirmationDetails :
confirmationType === 'edit'
? {
type : 'edit' ,
title : 'Confirm Edit' ,
onConfirm : mockOnConfirm ,
fileName : 'file.txt' ,
filePath : '/test/file.txt' ,
fileDiff : 'fake diff' ,
originalContent : 'old' ,
newContent : 'new' ,
}
: {
type : 'info' ,
title : ` ${ toolName } confirmation ` ,
onConfirm : mockOnConfirm ,
prompt : ` Execute ${ toolName } ? ` ,
} ,
tool : {
name : toolName ,
displayName : toolName ,
description : ` ${ toolName } description ` ,
build : vi.fn ( ) ,
} as any ,
invocation : {
getDescription : ( ) = > 'Mock description' ,
} as unknown as AnyToolInvocation ,
} ) ;
// Helper to render hook with default parameters - reduces boilerplate
const renderHookWithDefaults = (
options : {
shellModeActive? : boolean ;
onCancelSubmit ? : ( ) = > void ;
setShellInputFocused ? : ( focused : boolean ) = > void ;
performMemoryRefresh ? : ( ) = > Promise < void > ;
onAuthError ? : ( ) = > void ;
setModelSwitched? : Mock ;
modelSwitched? : boolean ;
} = { } ,
) = > {
const {
shellModeActive = false ,
onCancelSubmit = ( ) = > { } ,
setShellInputFocused = ( ) = > { } ,
performMemoryRefresh = ( ) = > Promise . resolve ( ) ,
onAuthError = ( ) = > { } ,
setModelSwitched = vi . fn ( ) ,
modelSwitched = false ,
} = options ;
return renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
mockLoadedSettings ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
shellModeActive ,
( ) = > 'vscode' as EditorType ,
onAuthError ,
performMemoryRefresh ,
modelSwitched ,
setModelSwitched ,
onCancelSubmit ,
setShellInputFocused ,
80 ,
24 ,
) ,
) ;
} ;
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-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-1' ,
2025-06-22 01:35:36 -04:00
} ,
2025-06-02 01:50:28 -07:00
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'call1' ,
responseParts : [ { text : 'tool 1 response' } ] ,
error : undefined ,
2025-08-20 15:51:31 -04:00
errorType : undefined , // FIX: Added missing property
2025-06-02 01:50:28 -07:00
resultDisplay : 'Tool 1 success display' ,
} ,
tool : {
name : 'tool1' ,
2025-08-06 10:50:02 -07:00
displayName : 'tool1' ,
2025-06-02 01:50:28 -07:00
description : 'desc1' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-06-02 01:50:28 -07:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
2025-06-02 01:50:28 -07:00
startTime : Date.now ( ) ,
endTime : Date.now ( ) ,
} as TrackedCompletedToolCall ,
{
2025-07-10 00:19:30 +05:30
request : {
callId : 'call2' ,
name : 'tool2' ,
args : { } ,
prompt_id : 'prompt-id-1' ,
} ,
2025-06-02 01:50:28 -07:00
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool2' ,
2025-08-06 10:50:02 -07:00
displayName : 'tool2' ,
2025-06-02 01:50:28 -07:00
description : 'desc2' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-06-02 01:50:28 -07:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
2025-06-02 01:50:28 -07:00
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 ( ) = > {
2025-08-22 14:12:05 -07:00
const toolCall1ResponseParts : Part [ ] = [ { text : 'tool 1 final response' } ] ;
const toolCall2ResponseParts : Part [ ] = [ { 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 ,
2025-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-2' ,
2025-06-22 01:35:36 -04:00
} ,
status : 'success' ,
responseSubmittedToGemini : false ,
2025-08-20 15:51:31 -04:00
response : {
callId : 'call1' ,
responseParts : toolCall1ResponseParts ,
errorType : undefined , // FIX: Added missing property
} ,
2025-08-06 10:50:02 -07:00
tool : {
displayName : 'MockTool' ,
} ,
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
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 ,
2025-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-2' ,
2025-06-22 01:35:36 -04:00
} ,
status : 'error' ,
responseSubmittedToGemini : false ,
2025-08-20 15:51:31 -04:00
response : {
callId : 'call2' ,
responseParts : toolCall2ResponseParts ,
errorType : ToolErrorType.UNHANDLED_EXCEPTION , // FIX: Added missing property
} ,
2025-06-22 01:35:36 -04:00
} 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 ;
2025-10-27 09:59:08 -07:00
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted , vi . fn ( ) ] ;
2025-06-27 16:39:54 -07:00
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-06-22 01:35:36 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-22 01:35:36 -04:00
) ,
) ;
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
2025-10-30 11:50:26 -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
} ) ;
2025-08-22 14:12:05 -07:00
const expectedMergedResponse = [
. . . 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-07-10 00:19:30 +05:30
'prompt-id-2' ,
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-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-3' ,
2025-06-08 11:14:45 -07:00
} ,
2025-06-22 01:35:36 -04:00
status : 'cancelled' ,
2025-08-20 15:51:31 -04:00
response : {
callId : '1' ,
responseParts : [ { text : 'cancelled' } ] ,
errorType : undefined , // FIX: Added missing property
} ,
2025-06-08 11:14:45 -07:00
responseSubmittedToGemini : false ,
2025-08-06 10:50:02 -07:00
tool : {
displayName : 'mock tool' ,
} ,
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
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 ;
2025-10-27 09:59:08 -07:00
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted , vi . fn ( ) ] ;
2025-06-27 16:39:54 -07:00
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
client ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-06-22 01:35:36 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-22 01:35:36 -04:00
) ,
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
} ) ;
2025-10-30 11:50:26 -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-07-05 13:56:39 -07:00
it ( 'should group multiple cancelled tool call responses into a single history entry' , async ( ) = > {
const cancelledToolCall1 : TrackedCancelledToolCall = {
request : {
callId : 'cancel-1' ,
name : 'toolA' ,
args : { } ,
isClientInitiated : false ,
2025-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-7' ,
2025-07-05 13:56:39 -07:00
} ,
tool : {
name : 'toolA' ,
2025-08-06 10:50:02 -07:00
displayName : 'toolA' ,
2025-07-05 13:56:39 -07:00
description : 'descA' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-07-05 13:56:39 -07:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
2025-07-05 13:56:39 -07:00
status : 'cancelled' ,
response : {
callId : 'cancel-1' ,
responseParts : [
{ functionResponse : { name : 'toolA' , id : 'cancel-1' } } ,
] ,
resultDisplay : undefined ,
error : undefined ,
2025-08-20 15:51:31 -04:00
errorType : undefined , // FIX: Added missing property
2025-07-05 13:56:39 -07:00
} ,
responseSubmittedToGemini : false ,
} ;
const cancelledToolCall2 : TrackedCancelledToolCall = {
request : {
callId : 'cancel-2' ,
name : 'toolB' ,
args : { } ,
isClientInitiated : false ,
2025-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-8' ,
2025-07-05 13:56:39 -07:00
} ,
tool : {
name : 'toolB' ,
2025-08-06 10:50:02 -07:00
displayName : 'toolB' ,
2025-07-05 13:56:39 -07:00
description : 'descB' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-07-05 13:56:39 -07:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
2025-07-05 13:56:39 -07:00
status : 'cancelled' ,
response : {
callId : 'cancel-2' ,
responseParts : [
{ functionResponse : { name : 'toolB' , id : 'cancel-2' } } ,
] ,
resultDisplay : undefined ,
error : undefined ,
2025-08-20 15:51:31 -04:00
errorType : undefined , // FIX: Added missing property
2025-07-05 13:56:39 -07:00
} ,
responseSubmittedToGemini : false ,
} ;
const allCancelledTools = [ cancelledToolCall1 , cancelledToolCall2 ] ;
const client = new MockedGeminiClientClass ( mockConfig ) ;
let capturedOnComplete :
| ( ( completedTools : TrackedToolCall [ ] ) = > Promise < void > )
| null = null ;
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
2025-10-27 09:59:08 -07:00
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted , vi . fn ( ) ] ;
2025-07-05 13:56:39 -07:00
} ) ;
renderHook ( ( ) = >
useGeminiStream (
client ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-07-05 13:56:39 -07:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-07-05 13:56:39 -07:00
) ,
) ;
// Trigger the onComplete callback with multiple cancelled tools
await act ( async ( ) = > {
if ( capturedOnComplete ) {
await capturedOnComplete ( allCancelledTools ) ;
}
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-05 13:56:39 -07:00
// The tools should be marked as submitted locally
expect ( mockMarkToolsAsSubmitted ) . toHaveBeenCalledWith ( [
'cancel-1' ,
'cancel-2' ,
] ) ;
// Crucially, addHistory should be called only ONCE
expect ( client . addHistory ) . toHaveBeenCalledTimes ( 1 ) ;
// And that single call should contain BOTH function responses
expect ( client . addHistory ) . toHaveBeenCalledWith ( {
role : 'user' ,
parts : [
. . . ( cancelledToolCall1 . response . responseParts as Part [ ] ) ,
. . . ( cancelledToolCall2 . response . responseParts as Part [ ] ) ,
] ,
} ) ;
// No message should be sent back to the API for a turn with only cancellations
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
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-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-4' ,
2025-06-27 16:39:54 -07:00
} ,
2025-06-19 18:25:23 -07:00
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool1' ,
2025-08-06 10:50:02 -07:00
displayName : 'tool1' ,
2025-06-19 18:25:23 -07:00
description : 'desc' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-06-19 18:25:23 -07:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
2025-06-19 18:25:23 -07:00
startTime : Date.now ( ) ,
} as TrackedExecutingToolCall ,
] ;
const completedToolCalls : TrackedToolCall [ ] = [
{
. . . ( initialToolCalls [ 0 ] as TrackedExecutingToolCall ) ,
status : 'success' ,
response : {
callId : 'call1' ,
responseParts : toolCallResponseParts ,
error : undefined ,
2025-08-20 15:51:31 -04:00
errorType : undefined , // FIX: Added missing property
2025-06-19 18:25:23 -07:00
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 ,
2025-10-27 09:59:08 -07:00
vi . fn ( ) , // setToolCallsForDisplay
2025-06-27 16:39:54 -07:00
] ;
} ) ;
const { result , rerender } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-06-27 16:39:54 -07:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-27 16:39:54 -07:00
) ,
) ;
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-10-27 09:59:08 -07:00
vi . fn ( ) , // setToolCallsForDisplay
2025-06-27 16:39:54 -07:00
] ;
} ) ;
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-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-19 18:25:23 -07:00
expect ( mockSendMessageStream ) . toHaveBeenCalledWith (
toolCallResponseParts ,
expect . any ( AbortSignal ) ,
2025-07-10 00:19:30 +05:30
'prompt-id-4' ,
2025-06-19 18:25:23 -07:00
) ;
} ) ;
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' , ( ) = > {
2025-08-12 14:05:49 -07:00
let keypressCallback : ( key : any ) = > void ;
const mockUseKeypress = useKeypress as Mock ;
2025-06-20 23:01:44 -04:00
beforeEach ( ( ) = > {
2025-08-12 14:05:49 -07:00
// Capture the callback passed to useKeypress
mockUseKeypress . mockImplementation ( ( callback , options ) = > {
if ( options . isActive ) {
keypressCallback = callback ;
} else {
keypressCallback = ( ) = > { } ;
}
2025-06-20 23:01:44 -04:00
} ) ;
} ) ;
const simulateEscapeKeyPress = ( ) = > {
act ( ( ) = > {
2025-08-12 14:05:49 -07:00
keypressCallback ( { name : 'escape' } ) ;
2025-06-20 23:01:44 -04:00
} ) ;
} ;
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
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-20 23:01:44 -04:00
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
} ) ;
// Simulate escape key press
simulateEscapeKeyPress ( ) ;
// Verify cancellation message is added
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-20 23:01:44 -04:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : MessageType . INFO ,
text : 'Request cancelled.' ,
} ,
expect . any ( Number ) ,
) ;
} ) ;
// Verify state is reset
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
} ) ;
2025-08-06 15:19:10 -04:00
it ( 'should call onCancelSubmit handler when escape is pressed' , async ( ) = > {
const cancelSubmitSpy = vi . fn ( ) ;
const mockStream = ( async function * ( ) {
yield { type : 'content' , value : 'Part 1' } ;
// Keep the stream open
await new Promise ( ( ) = > { } ) ;
} ) ( ) ;
mockSendMessageStream . mockReturnValue ( mockStream ) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
mockConfig . getGeminiClient ( ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-08-06 15:19:10 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
cancelSubmitSpy ,
2025-09-11 13:27:27 -07:00
( ) = > { } ,
80 ,
24 ,
2025-08-06 15:19:10 -04:00
) ,
) ;
// Start a query
await act ( async ( ) = > {
result . current . submitQuery ( 'test query' ) ;
} ) ;
simulateEscapeKeyPress ( ) ;
2025-11-19 11:11:36 +08:00
expect ( cancelSubmitSpy ) . toHaveBeenCalledWith ( false ) ;
2025-08-06 15:19:10 -04:00
} ) ;
2025-09-11 13:27:27 -07:00
it ( 'should call setShellInputFocused(false) when escape is pressed' , async ( ) = > {
const setShellInputFocusedSpy = vi . fn ( ) ;
const mockStream = ( async function * ( ) {
yield { type : 'content' , value : 'Part 1' } ;
await new Promise ( ( ) = > { } ) ; // Keep stream open
} ) ( ) ;
mockSendMessageStream . mockReturnValue ( mockStream ) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
mockConfig . getGeminiClient ( ) ,
[ ] ,
mockAddItem ,
mockConfig ,
mockLoadedSettings ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
vi . fn ( ) ,
setShellInputFocusedSpy , // Pass the spy here
80 ,
24 ,
) ,
) ;
// Start a query
await act ( async ( ) = > {
result . current . submitQuery ( 'test query' ) ;
} ) ;
simulateEscapeKeyPress ( ) ;
expect ( setShellInputFocusedSpy ) . toHaveBeenCalledWith ( false ) ;
} ) ;
2025-06-20 23:01:44 -04:00
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' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-20 23:01:44 -04:00
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
} ) ;
// Cancel the request
simulateEscapeKeyPress ( ) ;
// Allow the stream to continue
2025-10-30 11:50:26 -07:00
await act ( async ( ) = > {
2025-06-20 23:01:44 -04:00
continueStream ( ) ;
2025-10-30 11:50:26 -07:00
// Wait a bit to see if the second part is processed
await new Promise ( ( resolve ) = > setTimeout ( resolve , 50 ) ) ;
2025-06-20 23:01:44 -04:00
} ) ;
// 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 ) ;
} ) ;
2025-10-27 09:59:08 -07:00
it ( 'should cancel if a tool call is in progress' , async ( ) = > {
2025-06-20 23:01:44 -04:00
const toolCalls : TrackedToolCall [ ] = [
{
request : { callId : 'call1' , name : 'tool1' , args : { } } ,
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'tool1' ,
description : 'desc1' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) . mockImplementation ( ( _ ) = > ( {
getDescription : ( ) = > ` Mock description ` ,
} ) ) ,
2025-06-20 23:01:44 -04:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} ,
2025-06-20 23:01:44 -04:00
startTime : Date.now ( ) ,
liveOutput : '...' ,
} as TrackedExecutingToolCall ,
] ;
const { result } = renderTestHook ( toolCalls ) ;
// State is `Responding` because a tool is running
expect ( result . current . streamingState ) . toBe ( StreamingState . Responding ) ;
// Try to cancel
simulateEscapeKeyPress ( ) ;
2025-10-27 09:59:08 -07:00
// The cancel function should be called
expect ( mockCancelAllToolCalls ) . toHaveBeenCalled ( ) ;
} ) ;
it ( 'should cancel a request when a tool is awaiting confirmation' , async ( ) = > {
const mockOnConfirm = vi . fn ( ) . mockResolvedValue ( undefined ) ;
const toolCalls : TrackedToolCall [ ] = [
{
request : {
callId : 'confirm-call' ,
name : 'some_tool' ,
args : { } ,
isClientInitiated : false ,
prompt_id : 'prompt-id-1' ,
} ,
status : 'awaiting_approval' ,
responseSubmittedToGemini : false ,
tool : {
name : 'some_tool' ,
description : 'a tool' ,
build : vi.fn ( ) . mockImplementation ( ( _ ) = > ( {
getDescription : ( ) = > ` Mock description ` ,
} ) ) ,
} as any ,
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
confirmationDetails : {
type : 'edit' ,
title : 'Confirm Edit' ,
onConfirm : mockOnConfirm ,
fileName : 'file.txt' ,
filePath : '/test/file.txt' ,
fileDiff : 'fake diff' ,
originalContent : 'old' ,
newContent : 'new' ,
} ,
} as TrackedWaitingToolCall ,
] ;
const { result } = renderTestHook ( toolCalls ) ;
// State is `WaitingForConfirmation` because a tool is awaiting approval
expect ( result . current . streamingState ) . toBe (
StreamingState . WaitingForConfirmation ,
) ;
// Try to cancel
simulateEscapeKeyPress ( ) ;
// The imperative cancel function should be called on the scheduler
expect ( mockCancelAllToolCalls ) . toHaveBeenCalled ( ) ;
// A cancellation message should be added to history
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-27 09:59:08 -07:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
text : 'Request cancelled.' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
// The final state should be idle
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
2025-06-20 23:01:44 -04:00
} ) ;
} ) ;
2025-06-22 01:35:36 -04:00
2025-07-07 16:45:44 -04:00
describe ( 'Slash Command Handling' , ( ) = > {
it ( 'should schedule a tool call when the command processor returns a schedule_tool action' , async ( ) = > {
const clientToolRequest : SlashCommandProcessorResult = {
type : 'schedule_tool' ,
2025-06-22 01:35:36 -04:00
toolName : 'save_memory' ,
toolArgs : { fact : 'test fact' } ,
} ;
mockHandleSlashCommand . mockResolvedValue ( clientToolRequest ) ;
2025-07-07 16:45:44 -04:00
const { result } = renderTestHook ( ) ;
2025-06-22 01:35:36 -04:00
2025-07-07 16:45:44 -04:00
await act ( async ( ) = > {
await result . current . submitQuery ( '/memory add "test fact"' ) ;
} ) ;
2025-06-22 01:35:36 -04:00
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-07 16:45:44 -04:00
expect ( mockScheduleToolCalls ) . toHaveBeenCalledWith (
[
expect . objectContaining ( {
name : 'save_memory' ,
args : { fact : 'test fact' } ,
isClientInitiated : true ,
} ) ,
] ,
expect . any ( AbortSignal ) ,
) ;
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ;
2025-06-27 16:39:54 -07:00
} ) ;
2025-07-07 16:45:44 -04:00
} ) ;
2025-06-27 16:39:54 -07:00
2025-07-07 16:45:44 -04:00
it ( 'should stop processing and not call Gemini when a command is handled without a tool call' , async ( ) = > {
const uiOnlyCommandResult : SlashCommandProcessorResult = {
type : 'handled' ,
} ;
mockHandleSlashCommand . mockResolvedValue ( uiOnlyCommandResult ) ;
2025-06-22 01:35:36 -04:00
2025-07-07 16:45:44 -04:00
const { result } = renderTestHook ( ) ;
2025-06-22 01:35:36 -04:00
2025-06-27 16:39:54 -07:00
await act ( async ( ) = > {
2025-07-07 16:45:44 -04:00
await result . current . submitQuery ( '/help' ) ;
2025-06-22 01:35:36 -04:00
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-07 16:45:44 -04:00
expect ( mockHandleSlashCommand ) . toHaveBeenCalledWith ( '/help' ) ;
expect ( mockScheduleToolCalls ) . not . toHaveBeenCalled ( ) ;
expect ( mockSendMessageStream ) . not . toHaveBeenCalled ( ) ; // No LLM call made
2025-06-22 01:35:36 -04:00
} ) ;
} ) ;
2025-07-22 00:34:55 -04:00
it ( 'should call Gemini with prompt content when slash command returns a `submit_prompt` action' , async ( ) = > {
const customCommandResult : SlashCommandProcessorResult = {
type : 'submit_prompt' ,
content : 'This is the actual prompt from the command file.' ,
} ;
mockHandleSlashCommand . mockResolvedValue ( customCommandResult ) ;
const { result , mockSendMessageStream : localMockSendMessageStream } =
renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( '/my-custom-command' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-22 00:34:55 -04:00
expect ( mockHandleSlashCommand ) . toHaveBeenCalledWith (
'/my-custom-command' ,
) ;
expect ( localMockSendMessageStream ) . not . toHaveBeenCalledWith (
'/my-custom-command' ,
expect . anything ( ) ,
expect . anything ( ) ,
) ;
expect ( localMockSendMessageStream ) . toHaveBeenCalledWith (
'This is the actual prompt from the command file.' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
expect ( mockScheduleToolCalls ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
it ( 'should correctly handle a submit_prompt action with empty content' , async ( ) = > {
const emptyPromptResult : SlashCommandProcessorResult = {
type : 'submit_prompt' ,
content : '' ,
} ;
mockHandleSlashCommand . mockResolvedValue ( emptyPromptResult ) ;
const { result , mockSendMessageStream : localMockSendMessageStream } =
renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( '/emptycmd' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-22 00:34:55 -04:00
expect ( mockHandleSlashCommand ) . toHaveBeenCalledWith ( '/emptycmd' ) ;
expect ( localMockSendMessageStream ) . toHaveBeenCalledWith (
'' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
} ) ;
} ) ;
2025-08-26 11:51:27 +08:00
it ( 'should not call handleSlashCommand for line comments' , async ( ) = > {
const { result , mockSendMessageStream : localMockSendMessageStream } =
renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( '// This is a line comment' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-26 11:51:27 +08:00
expect ( mockHandleSlashCommand ) . not . toHaveBeenCalled ( ) ;
expect ( localMockSendMessageStream ) . toHaveBeenCalledWith (
'// This is a line comment' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
} ) ;
} ) ;
it ( 'should not call handleSlashCommand for block comments' , async ( ) = > {
const { result , mockSendMessageStream : localMockSendMessageStream } =
renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( '/* This is a block comment */' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-26 11:51:27 +08:00
expect ( mockHandleSlashCommand ) . not . toHaveBeenCalled ( ) ;
expect ( localMockSendMessageStream ) . toHaveBeenCalledWith (
'/* This is a block comment */' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
} ) ;
} ) ;
2025-10-17 23:00:27 +05:30
it ( 'should not call handleSlashCommand is shell mode is active' , async ( ) = > {
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
mockLoadedSettings ,
( ) = > { } ,
mockHandleSlashCommand ,
true ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
( ) = > { } ,
( ) = > { } ,
80 ,
24 ,
) ,
) ;
await act ( async ( ) = > {
await result . current . submitQuery ( '/about' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-17 23:00:27 +05:30
expect ( mockHandleSlashCommand ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
2025-06-22 01:35:36 -04:00
} ) ;
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 ,
2025-07-10 00:19:30 +05:30
prompt_id : 'prompt-id-6' ,
2025-06-22 01:35:36 -04:00
} ,
status : 'success' ,
responseSubmittedToGemini : false ,
response : {
callId : 'save-mem-call-1' ,
responseParts : [ { text : 'Memory saved' } ] ,
resultDisplay : 'Success: Memory saved' ,
error : undefined ,
2025-08-20 15:51:31 -04:00
errorType : undefined , // FIX: Added missing property
2025-06-22 01:35:36 -04:00
} ,
tool : {
name : 'save_memory' ,
2025-08-06 10:50:02 -07:00
displayName : 'save_memory' ,
2025-06-22 01:35:36 -04:00
description : 'Saves memory' ,
2025-08-06 10:50:02 -07:00
build : vi.fn ( ) ,
2025-06-22 01:35:36 -04:00
} as any ,
2025-08-06 10:50:02 -07:00
invocation : {
getDescription : ( ) = > ` Mock description ` ,
} as unknown as AnyToolInvocation ,
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 ;
2025-06-22 01:35:36 -04:00
2025-06-27 16:39:54 -07:00
mockUseReactToolScheduler . mockImplementation ( ( onComplete ) = > {
capturedOnComplete = onComplete ;
2025-10-27 09:59:08 -07:00
return [ [ ] , mockScheduleToolCalls , mockMarkToolsAsSubmitted , vi . fn ( ) ] ;
2025-06-27 16:39:54 -07:00
} ) ;
renderHook ( ( ) = >
2025-06-22 01:35:36 -04:00
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-06-22 01:35:36 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
mockPerformMemoryRefresh ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-22 01:35:36 -04:00
) ,
) ;
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
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-22 01:35:36 -04:00
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-30 17:11:54 -07:00
const mockAuthType = AuthType . LOGIN_WITH_GOOGLE ;
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 ,
} ) ) ,
2025-07-09 10:18:15 -04:00
getModel : vi.fn ( ( ) = > 'gemini-2.5-pro' ) ,
2025-06-23 23:43:00 -04:00
} as unknown as Config ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( testConfig ) ,
[ ] ,
mockAddItem ,
testConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-06-23 23:43:00 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
2025-07-09 13:55:56 -04:00
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-06-23 23:43:00 -04:00
) ,
) ;
// 2. Action
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
// 3. Assertion
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-06-23 23:43:00 -04:00
expect ( mockParseAndFormatApiError ) . toHaveBeenCalledWith (
'Rate limit exceeded' ,
mockAuthType ,
2025-07-09 10:18:15 -04:00
undefined ,
'gemini-2.5-pro' ,
'gemini-2.5-flash' ,
2025-06-23 23:43:00 -04:00
) ;
} ) ;
} ) ;
} ) ;
2025-07-22 06:57:11 +09:00
2025-09-14 20:20:21 -07:00
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 [ ] = [
2025-11-03 23:40:57 +05:30
createMockToolCall ( 'replace' , 'call1' , 'edit' , mockOnConfirm ) ,
createMockToolCall ( 'read_file' , 'call2' , 'info' , mockOnConfirm ) ,
2025-09-14 20:20:21 -07:00
] ;
const { result } = renderTestHook ( awaitingApprovalToolCalls ) ;
await act ( async ( ) = > {
await result . current . handleApprovalModeChange ( ApprovalMode . YOLO ) ;
} ) ;
// Both tool calls should be auto-approved
expect ( mockOnConfirm ) . toHaveBeenCalledTimes ( 2 ) ;
2025-11-03 23:40:57 +05:30
expect ( mockOnConfirm ) . toHaveBeenCalledWith (
2025-09-14 20:20:21 -07:00
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 [ ] = [
2025-11-03 23:40:57 +05:30
createMockToolCall ( 'replace' , 'call1' , 'edit' , mockOnConfirmReplace ) ,
createMockToolCall ( 'write_file' , 'call2' , 'edit' , mockOnConfirmWrite ) ,
createMockToolCall ( 'read_file' , 'call3' , 'info' , mockOnConfirmRead ) ,
2025-09-14 20:20:21 -07:00
] ;
const { result } = renderTestHook ( awaitingApprovalToolCalls ) ;
await act ( async ( ) = > {
await result . current . handleApprovalModeChange ( ApprovalMode . AUTO_EDIT ) ;
} ) ;
// Only replace and write_file should be auto-approved
expect ( mockOnConfirmReplace ) . toHaveBeenCalledWith (
ToolConfirmationOutcome . ProceedOnce ,
) ;
expect ( mockOnConfirmWrite ) . toHaveBeenCalledWith (
ToolConfirmationOutcome . ProceedOnce ,
) ;
// 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 [ ] = [
2025-11-03 23:40:57 +05:30
createMockToolCall ( 'replace' , 'call1' , 'edit' , mockOnConfirm ) ,
2025-09-14 20:20:21 -07:00
] ;
const { result } = renderTestHook ( awaitingApprovalToolCalls ) ;
await act ( async ( ) = > {
2025-10-19 17:16:16 -07:00
await result . current . handleApprovalModeChange ( ApprovalMode . DEFAULT ) ;
2025-09-14 20:20:21 -07:00
} ) ;
// No tools should be auto-approved
expect ( mockOnConfirm ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( 'should handle errors gracefully when auto-approving tool calls' , async ( ) = > {
2025-10-28 19:05:48 +00:00
const debuggerSpy = vi
. spyOn ( debugLogger , 'warn' )
2025-09-14 20:20:21 -07:00
. mockImplementation ( ( ) = > { } ) ;
const mockOnConfirmSuccess = vi . fn ( ) . mockResolvedValue ( undefined ) ;
const mockOnConfirmError = vi
. fn ( )
. mockRejectedValue ( new Error ( 'Approval failed' ) ) ;
const awaitingApprovalToolCalls : TrackedToolCall [ ] = [
2025-11-03 23:40:57 +05:30
createMockToolCall ( 'replace' , 'call1' , 'edit' , mockOnConfirmSuccess ) ,
createMockToolCall ( 'write_file' , 'call2' , 'edit' , mockOnConfirmError ) ,
2025-09-14 20:20:21 -07:00
] ;
const { result } = renderTestHook ( awaitingApprovalToolCalls ) ;
await act ( async ( ) = > {
await result . current . handleApprovalModeChange ( ApprovalMode . YOLO ) ;
} ) ;
// Both confirmation methods should be called
2025-11-03 23:40:57 +05:30
expect ( mockOnConfirmSuccess ) . toHaveBeenCalled ( ) ;
expect ( mockOnConfirmError ) . toHaveBeenCalled ( ) ;
2025-09-14 20:20:21 -07:00
// Error should be logged
2025-10-28 19:05:48 +00:00
expect ( debuggerSpy ) . toHaveBeenCalledWith (
2025-09-14 20:20:21 -07:00
'Failed to auto-approve tool call call2:' ,
expect . any ( Error ) ,
) ;
2025-10-28 19:05:48 +00:00
debuggerSpy . mockRestore ( ) ;
2025-09-14 20:20:21 -07:00
} ) ;
it ( 'should skip tool calls without 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 ,
// No confirmationDetails
tool : {
name : 'replace' ,
displayName : 'replace' ,
description : 'Replace text' ,
build : vi.fn ( ) ,
} as any ,
invocation : {
getDescription : ( ) = > 'Mock description' ,
} as unknown as AnyToolInvocation ,
2025-10-19 17:16:16 -07:00
} as unknown as TrackedWaitingToolCall ,
2025-09-14 20:20:21 -07:00
] ;
const { result } = renderTestHook ( awaitingApprovalToolCalls ) ;
// Should not throw an error
await act ( async ( ) = > {
await result . current . handleApprovalModeChange ( ApprovalMode . YOLO ) ;
} ) ;
} ) ;
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 : {
2025-10-19 17:16:16 -07:00
type : 'edit' ,
title : 'Confirm Edit' ,
2025-09-14 20:20:21 -07:00
// No onConfirm method
2025-10-19 17:16:16 -07:00
fileName : 'file.txt' ,
filePath : '/test/file.txt' ,
fileDiff : 'fake diff' ,
originalContent : 'old' ,
newContent : 'new' ,
2025-09-14 20:20:21 -07:00
} 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 : {
2025-10-19 17:16:16 -07:00
type : 'edit' ,
title : 'Confirm Edit' ,
2025-09-14 20:20:21 -07:00
onConfirm : mockOnConfirmAwaiting ,
2025-10-19 17:16:16 -07:00
fileName : 'file.txt' ,
filePath : '/test/file.txt' ,
fileDiff : 'fake diff' ,
originalContent : 'old' ,
newContent : 'new' ,
2025-09-14 20:20:21 -07:00
} ,
tool : {
name : 'replace' ,
displayName : 'replace' ,
description : 'Replace text' ,
build : vi.fn ( ) ,
} as any ,
invocation : {
getDescription : ( ) = > 'Mock description' ,
} as unknown as AnyToolInvocation ,
} as TrackedWaitingToolCall ,
{
request : {
callId : 'call2' ,
name : 'write_file' ,
args : { path : '/test/file.txt' , content : 'content' } ,
isClientInitiated : false ,
prompt_id : 'prompt-id-1' ,
} ,
status : 'executing' ,
responseSubmittedToGemini : false ,
tool : {
name : 'write_file' ,
displayName : 'write_file' ,
description : 'Write file' ,
build : vi.fn ( ) ,
} as any ,
invocation : {
getDescription : ( ) = > 'Mock description' ,
} as unknown as AnyToolInvocation ,
startTime : Date.now ( ) ,
liveOutput : 'Writing...' ,
} as TrackedExecutingToolCall ,
] ;
const { result } = renderTestHook ( mixedStatusToolCalls ) ;
await act ( async ( ) = > {
await result . current . handleApprovalModeChange ( ApprovalMode . YOLO ) ;
} ) ;
// Only the awaiting_approval tool should be processed
expect ( mockOnConfirmAwaiting ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnConfirmExecuting ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
2025-07-22 06:57:11 +09:00
describe ( 'handleFinishedEvent' , ( ) = > {
it ( 'should add info message for MAX_TOKENS finish reason' , async ( ) = > {
// Setup mock to return a stream with MAX_TOKENS finish reason
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'This is a truncated response...' ,
} ;
2025-09-02 23:29:07 -06:00
yield {
type : ServerGeminiEventType . Finished ,
value : { reason : 'MAX_TOKENS' , usageMetadata : undefined } ,
} ;
2025-07-22 06:57:11 +09:00
} ) ( ) ,
) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-07-22 06:57:11 +09:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
2025-08-06 15:19:10 -04:00
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-07-22 06:57:11 +09:00
) ,
) ;
// Submit a query
await act ( async ( ) = > {
await result . current . submitQuery ( 'Generate long text' ) ;
} ) ;
// Check that the info message was added
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-07-22 06:57:11 +09:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
text : '⚠️ Response truncated due to token limits.' ,
} ,
expect . any ( Number ) ,
) ;
} ) ;
} ) ;
2025-10-09 10:22:26 -07:00
describe ( 'ContextWindowWillOverflow event' , ( ) = > {
beforeEach ( ( ) = > {
vi . mocked ( tokenLimit ) . mockReturnValue ( 100 ) ;
} ) ;
2025-10-08 15:20:44 -07:00
2025-11-03 23:40:57 +05:30
it . each ( [
{
name : 'without suggestion when remaining tokens are > 75% of limit' ,
requestTokens : 20 ,
remainingTokens : 80 ,
expectedMessage :
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).' ,
} ,
{
name : 'with suggestion when remaining tokens are < 75% of limit' ,
requestTokens : 30 ,
remainingTokens : 70 ,
expectedMessage :
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.' ,
} ,
] ) (
'should add message $name' ,
async ( { requestTokens , remainingTokens , expectedMessage } ) = > {
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . ContextWindowWillOverflow ,
value : {
estimatedRequestTokenCount : requestTokens ,
remainingTokenCount : remainingTokens ,
} ,
} ;
} ) ( ) ,
2025-10-09 10:22:26 -07:00
) ;
2025-10-08 15:20:44 -07:00
2025-11-03 23:40:57 +05:30
const { result } = renderHookWithDefaults ( ) ;
2025-10-09 10:22:26 -07:00
2025-11-03 23:40:57 +05:30
await act ( async ( ) = > {
await result . current . submitQuery ( 'Test overflow' ) ;
} ) ;
2025-10-09 10:22:26 -07:00
2025-11-03 23:40:57 +05:30
await waitFor ( ( ) = > {
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
text : expectedMessage ,
} ,
expect . any ( Number ) ,
) ;
} ) ;
} ,
) ;
2025-10-08 15:20:44 -07:00
} ) ;
it ( 'should call onCancelSubmit when ContextWindowWillOverflow event is received' , async ( ) = > {
const onCancelSubmitSpy = vi . fn ( ) ;
// Setup mock to return a stream with ContextWindowWillOverflow event
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . ContextWindowWillOverflow ,
value : {
estimatedRequestTokenCount : 100 ,
remainingTokenCount : 50 ,
} ,
} ;
} ) ( ) ,
) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
mockLoadedSettings ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
onCancelSubmitSpy ,
( ) = > { } ,
80 ,
24 ,
) ,
) ;
// Submit a query
await act ( async ( ) = > {
await result . current . submitQuery ( 'Test overflow' ) ;
} ) ;
// Check that onCancelSubmit was called
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-11-19 11:11:36 +08:00
expect ( onCancelSubmitSpy ) . toHaveBeenCalledWith ( true ) ;
2025-10-08 15:20:44 -07:00
} ) ;
} ) ;
2025-11-03 23:40:57 +05:30
it . each ( [
{
reason : 'STOP' ,
shouldAddMessage : false ,
} ,
{
reason : 'FINISH_REASON_UNSPECIFIED' ,
shouldAddMessage : false ,
} ,
{
reason : 'SAFETY' ,
message : '⚠️ Response stopped due to safety reasons.' ,
} ,
{
reason : 'RECITATION' ,
message : '⚠️ Response stopped due to recitation policy.' ,
} ,
{
reason : 'LANGUAGE' ,
message : '⚠️ Response stopped due to unsupported language.' ,
} ,
{
reason : 'BLOCKLIST' ,
message : '⚠️ Response stopped due to forbidden terms.' ,
} ,
{
reason : 'PROHIBITED_CONTENT' ,
message : '⚠️ Response stopped due to prohibited content.' ,
} ,
{
reason : 'SPII' ,
message :
'⚠️ Response stopped due to sensitive personally identifiable information.' ,
} ,
{
reason : 'OTHER' ,
message : '⚠️ Response stopped for other reasons.' ,
} ,
{
reason : 'MALFORMED_FUNCTION_CALL' ,
message : '⚠️ Response stopped due to malformed function call.' ,
} ,
{
reason : 'IMAGE_SAFETY' ,
message : '⚠️ Response stopped due to image safety violations.' ,
} ,
{
reason : 'UNEXPECTED_TOOL_CALL' ,
message : '⚠️ Response stopped due to unexpected tool call.' ,
} ,
] ) (
'should handle $reason finish reason correctly' ,
async ( { reason , shouldAddMessage = true , message } ) = > {
2025-07-22 06:57:11 +09:00
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : ` Response for ${ reason } ` ,
} ;
2025-09-02 23:29:07 -06:00
yield {
type : ServerGeminiEventType . Finished ,
value : { reason , usageMetadata : undefined } ,
} ;
2025-07-22 06:57:11 +09:00
} ) ( ) ,
) ;
2025-11-03 23:40:57 +05:30
const { result } = renderHookWithDefaults ( ) ;
2025-07-22 06:57:11 +09:00
await act ( async ( ) = > {
await result . current . submitQuery ( ` Test ${ reason } ` ) ;
} ) ;
2025-11-03 23:40:57 +05:30
if ( shouldAddMessage ) {
await waitFor ( ( ) = > {
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
text : message ,
} ,
expect . any ( Number ) ,
) ;
} ) ;
} else {
// Verify state returns to idle without any info messages
await waitFor ( ( ) = > {
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
} ) ;
const infoMessages = mockAddItem . mock . calls . filter (
( call ) = > call [ 0 ] . type === 'info' ,
2025-07-22 06:57:11 +09:00
) ;
2025-11-03 23:40:57 +05:30
expect ( infoMessages ) . toHaveLength ( 0 ) ;
}
} ,
) ;
2025-07-22 06:57:11 +09:00
} ) ;
2025-07-28 23:27:33 +05:30
2025-08-20 15:51:31 -04:00
it ( 'should process @include commands, adding user turn after processing to prevent race conditions' , async ( ) = > {
const rawQuery = '@include file.txt Summarize this.' ;
const processedQueryParts = [
{ text : 'Summarize this with content from @file.txt' } ,
{ text : 'File content...' } ,
] ;
const userMessageTimestamp = Date . now ( ) ;
vi . spyOn ( Date , 'now' ) . mockReturnValue ( userMessageTimestamp ) ;
handleAtCommandSpy . mockResolvedValue ( {
processedQuery : processedQueryParts ,
shouldProceed : true ,
} ) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
mockConfig . getGeminiClient ( ) as GeminiClient ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-08-20 15:51:31 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false , // shellModeActive
vi . fn ( ) , // getPreferredEditor
vi . fn ( ) , // onAuthError
vi . fn ( ) , // performMemoryRefresh
false , // modelSwitched
vi . fn ( ) , // setModelSwitched
vi . fn ( ) , // onCancelSubmit
2025-09-11 13:27:27 -07:00
vi . fn ( ) , // setShellInputFocused
80 , // terminalWidth
24 , // terminalHeight
2025-08-20 15:51:31 -04:00
) ,
) ;
await act ( async ( ) = > {
await result . current . submitQuery ( rawQuery ) ;
} ) ;
expect ( handleAtCommandSpy ) . toHaveBeenCalledWith (
expect . objectContaining ( {
query : rawQuery ,
} ) ,
) ;
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : MessageType . USER ,
text : rawQuery ,
} ,
userMessageTimestamp ,
) ;
2025-08-22 15:43:53 -04:00
// FIX: The expectation now matches the actual call signature.
2025-08-20 15:51:31 -04:00
expect ( mockSendMessageStream ) . toHaveBeenCalledWith (
2025-08-22 15:43:53 -04:00
processedQueryParts , // Argument 1: The parts array directly
expect . any ( AbortSignal ) , // Argument 2: An AbortSignal
expect . any ( String ) , // Argument 3: The prompt_id string
) ;
} ) ;
describe ( 'Thought Reset' , ( ) = > {
it ( 'should reset thought to null when starting a new prompt' , async ( ) = > {
2025-08-25 16:21:47 +02:00
// First, simulate a response with a thought
2025-08-22 15:43:53 -04:00
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Thought ,
value : {
subject : 'Previous thought' ,
description : 'Old description' ,
} ,
} ;
yield {
type : ServerGeminiEventType . Content ,
value : 'Some response content' ,
} ;
2025-09-02 23:29:07 -06:00
yield {
type : ServerGeminiEventType . Finished ,
value : { reason : 'STOP' , usageMetadata : undefined } ,
} ;
2025-08-22 15:43:53 -04:00
} ) ( ) ,
) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-08-22 15:43:53 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-08-22 15:43:53 -04:00
) ,
) ;
2025-08-25 16:21:47 +02:00
// Submit first query to set a thought
2025-08-22 15:43:53 -04:00
await act ( async ( ) = > {
await result . current . submitQuery ( 'First query' ) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Wait for the first response to complete
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-22 15:43:53 -04:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'gemini' ,
text : 'Some response content' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Now simulate a new response without a thought
2025-08-22 15:43:53 -04:00
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'New response content' ,
} ;
2025-09-02 23:29:07 -06:00
yield {
type : ServerGeminiEventType . Finished ,
value : { reason : 'STOP' , usageMetadata : undefined } ,
} ;
2025-08-22 15:43:53 -04:00
} ) ( ) ,
) ;
2025-08-25 16:21:47 +02:00
// Submit second query - thought should be reset
2025-08-22 15:43:53 -04:00
await act ( async ( ) = > {
await result . current . submitQuery ( 'Second query' ) ;
} ) ;
2025-08-25 16:21:47 +02:00
// The thought should be reset to null when starting the new prompt
// We can verify this by checking that the LoadingIndicator would not show the previous thought
// The actual thought state is internal to the hook, but we can verify the behavior
// by ensuring the second response doesn't show the previous thought
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-22 15:43:53 -04:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'gemini' ,
text : 'New response content' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
} ) ;
2025-09-17 15:37:13 -07:00
it ( 'should memoize pendingHistoryItems' , ( ) = > {
mockUseReactToolScheduler . mockReturnValue ( [
[ ] ,
mockScheduleToolCalls ,
mockCancelAllToolCalls ,
mockMarkToolsAsSubmitted ,
] ) ;
const { result , rerender } = renderHook ( ( ) = >
useGeminiStream (
mockConfig . getGeminiClient ( ) ,
[ ] ,
mockAddItem ,
mockConfig ,
mockLoadedSettings ,
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
( ) = > { } ,
( ) = > { } ,
80 ,
24 ,
) ,
) ;
const firstResult = result . current . pendingHistoryItems ;
rerender ( ) ;
const secondResult = result . current . pendingHistoryItems ;
expect ( firstResult ) . toStrictEqual ( secondResult ) ;
const newToolCalls : TrackedToolCall [ ] = [
{
request : { callId : 'call1' , name : 'tool1' , args : { } } ,
status : 'executing' ,
tool : {
name : 'tool1' ,
displayName : 'tool1' ,
description : 'desc1' ,
build : vi.fn ( ) ,
} ,
invocation : {
getDescription : ( ) = > 'Mock description' ,
} ,
} as unknown as TrackedExecutingToolCall ,
] ;
mockUseReactToolScheduler . mockReturnValue ( [
newToolCalls ,
mockScheduleToolCalls ,
mockCancelAllToolCalls ,
mockMarkToolsAsSubmitted ,
] ) ;
rerender ( ) ;
const thirdResult = result . current . pendingHistoryItems ;
expect ( thirdResult ) . not . toStrictEqual ( secondResult ) ;
} ) ;
2025-08-22 15:43:53 -04:00
it ( 'should reset thought to null when user cancels' , async ( ) = > {
2025-08-25 16:21:47 +02:00
// Mock a stream that yields a thought then gets cancelled
2025-08-22 15:43:53 -04:00
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Thought ,
value : { subject : 'Some thought' , description : 'Description' } ,
} ;
yield { type : ServerGeminiEventType . UserCancelled } ;
} ) ( ) ,
) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-08-22 15:43:53 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-08-22 15:43:53 -04:00
) ,
) ;
2025-08-25 16:21:47 +02:00
// Submit query
2025-08-22 15:43:53 -04:00
await act ( async ( ) = > {
await result . current . submitQuery ( 'Test query' ) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Verify cancellation message was added
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-22 15:43:53 -04:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'info' ,
text : 'User cancelled the request.' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Verify state is reset to idle
2025-08-22 15:43:53 -04:00
expect ( result . current . streamingState ) . toBe ( StreamingState . Idle ) ;
} ) ;
it ( 'should reset thought to null when there is an error' , async ( ) = > {
2025-08-25 16:21:47 +02:00
// Mock a stream that yields a thought then encounters an error
2025-08-22 15:43:53 -04:00
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Thought ,
value : { subject : 'Some thought' , description : 'Description' } ,
} ;
yield {
type : ServerGeminiEventType . Error ,
value : { error : { message : 'Test error' } } ,
} ;
} ) ( ) ,
) ;
const { result } = renderHook ( ( ) = >
useGeminiStream (
new MockedGeminiClientClass ( mockConfig ) ,
[ ] ,
mockAddItem ,
mockConfig ,
2025-08-28 16:42:54 -07:00
mockLoadedSettings ,
2025-08-22 15:43:53 -04:00
mockOnDebugMessage ,
mockHandleSlashCommand ,
false ,
( ) = > 'vscode' as EditorType ,
( ) = > { } ,
( ) = > Promise . resolve ( ) ,
false ,
( ) = > { } ,
( ) = > { } ,
( ) = > { } ,
2025-09-11 13:27:27 -07:00
80 ,
24 ,
2025-08-22 15:43:53 -04:00
) ,
) ;
2025-08-25 16:21:47 +02:00
// Submit query
2025-08-22 15:43:53 -04:00
await act ( async ( ) = > {
await result . current . submitQuery ( 'Test query' ) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Verify error message was added
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-08-22 15:43:53 -04:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'error' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
2025-08-25 16:21:47 +02:00
// Verify parseAndFormatApiError was called
2025-08-22 15:43:53 -04:00
expect ( mockParseAndFormatApiError ) . toHaveBeenCalledWith (
{ message : 'Test error' } ,
expect . any ( String ) ,
undefined ,
'gemini-2.5-pro' ,
'gemini-2.5-flash' ,
) ;
} ) ;
} ) ;
2025-09-10 22:20:13 -07:00
describe ( 'Loop Detection Confirmation' , ( ) = > {
beforeEach ( ( ) = > {
// Add mock for getLoopDetectionService to the config
const mockLoopDetectionService = {
disableForSession : vi.fn ( ) ,
} ;
mockConfig . getGeminiClient = vi . fn ( ) . mockReturnValue ( {
. . . new MockedGeminiClientClass ( mockConfig ) ,
getLoopDetectionService : ( ) = > mockLoopDetectionService ,
} ) ;
} ) ;
it ( 'should set loopDetectionConfirmationRequest when LoopDetected event is received' , async ( ) = > {
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'Some content' ,
} ;
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
const { result } = renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
expect (
typeof result . current . loopDetectionConfirmationRequest ? . onComplete ,
) . toBe ( 'function' ) ;
} ) ;
} ) ;
it ( 'should disable loop detection and show message when user selects "disable"' , async ( ) = > {
const mockLoopDetectionService = {
disableForSession : vi.fn ( ) ,
} ;
const mockClient = {
. . . new MockedGeminiClientClass ( mockConfig ) ,
getLoopDetectionService : ( ) = > mockLoopDetectionService ,
} ;
mockConfig . getGeminiClient = vi . fn ( ) . mockReturnValue ( mockClient ) ;
2025-10-21 13:27:57 -07:00
// Mock for the initial request
mockSendMessageStream . mockReturnValueOnce (
2025-09-10 22:20:13 -07:00
( async function * ( ) {
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
2025-10-21 13:27:57 -07:00
// Mock for the retry request
mockSendMessageStream . mockReturnValueOnce (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'Retry successful' ,
} ;
yield {
type : ServerGeminiEventType . Finished ,
value : { reason : 'STOP' } ,
} ;
} ) ( ) ,
) ;
2025-09-10 22:20:13 -07:00
const { result } = renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
// Wait for confirmation request to be set
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
} ) ;
// Simulate user selecting "disable"
await act ( async ( ) = > {
result . current . loopDetectionConfirmationRequest ? . onComplete ( {
userSelection : 'disable' ,
} ) ;
} ) ;
// Verify loop detection was disabled
expect ( mockLoopDetectionService . disableForSession ) . toHaveBeenCalledTimes (
1 ,
) ;
// Verify confirmation request was cleared
expect ( result . current . loopDetectionConfirmationRequest ) . toBeNull ( ) ;
// Verify appropriate message was added
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
2025-10-21 13:27:57 -07:00
text : 'Loop detection has been disabled for this session. Retrying request...' ,
2025-09-10 22:20:13 -07:00
} ,
expect . any ( Number ) ,
) ;
2025-10-21 13:27:57 -07:00
// Verify that the request was retried
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-21 13:27:57 -07:00
expect ( mockSendMessageStream ) . toHaveBeenCalledTimes ( 2 ) ;
expect ( mockSendMessageStream ) . toHaveBeenNthCalledWith (
2 ,
'test query' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
} ) ;
2025-09-10 22:20:13 -07:00
} ) ;
it ( 'should keep loop detection enabled and show message when user selects "keep"' , async ( ) = > {
const mockLoopDetectionService = {
disableForSession : vi.fn ( ) ,
} ;
const mockClient = {
. . . new MockedGeminiClientClass ( mockConfig ) ,
getLoopDetectionService : ( ) = > mockLoopDetectionService ,
} ;
mockConfig . getGeminiClient = vi . fn ( ) . mockReturnValue ( mockClient ) ;
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
const { result } = renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
// Wait for confirmation request to be set
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
} ) ;
// Simulate user selecting "keep"
await act ( async ( ) = > {
result . current . loopDetectionConfirmationRequest ? . onComplete ( {
userSelection : 'keep' ,
} ) ;
} ) ;
// Verify loop detection was NOT disabled
expect ( mockLoopDetectionService . disableForSession ) . not . toHaveBeenCalled ( ) ;
// Verify confirmation request was cleared
expect ( result . current . loopDetectionConfirmationRequest ) . toBeNull ( ) ;
// Verify appropriate message was added
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
text : 'A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.' ,
} ,
expect . any ( Number ) ,
) ;
2025-10-21 13:27:57 -07:00
// Verify that the request was NOT retried
expect ( mockSendMessageStream ) . toHaveBeenCalledTimes ( 1 ) ;
2025-09-10 22:20:13 -07:00
} ) ;
it ( 'should handle multiple loop detection events properly' , async ( ) = > {
const { result } = renderTestHook ( ) ;
// First loop detection - set up fresh mock for first call
mockSendMessageStream . mockReturnValueOnce (
( async function * ( ) {
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
// First loop detection
await act ( async ( ) = > {
await result . current . submitQuery ( 'first query' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
} ) ;
// Simulate user selecting "keep" for first request
await act ( async ( ) = > {
result . current . loopDetectionConfirmationRequest ? . onComplete ( {
userSelection : 'keep' ,
} ) ;
} ) ;
expect ( result . current . loopDetectionConfirmationRequest ) . toBeNull ( ) ;
// Verify first message was added
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
text : 'A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.' ,
} ,
expect . any ( Number ) ,
) ;
// Second loop detection - set up fresh mock for second call
mockSendMessageStream . mockReturnValueOnce (
( async function * ( ) {
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
2025-10-21 13:27:57 -07:00
// Mock for the retry request
mockSendMessageStream . mockReturnValueOnce (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'Retry successful' ,
} ;
yield {
type : ServerGeminiEventType . Finished ,
value : { reason : 'STOP' } ,
} ;
} ) ( ) ,
) ;
2025-09-10 22:20:13 -07:00
// Second loop detection
await act ( async ( ) = > {
await result . current . submitQuery ( 'second query' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
} ) ;
// Simulate user selecting "disable" for second request
await act ( async ( ) = > {
result . current . loopDetectionConfirmationRequest ? . onComplete ( {
userSelection : 'disable' ,
} ) ;
} ) ;
expect ( result . current . loopDetectionConfirmationRequest ) . toBeNull ( ) ;
// Verify second message was added
expect ( mockAddItem ) . toHaveBeenCalledWith (
{
type : 'info' ,
2025-10-21 13:27:57 -07:00
text : 'Loop detection has been disabled for this session. Retrying request...' ,
2025-09-10 22:20:13 -07:00
} ,
expect . any ( Number ) ,
) ;
2025-10-21 13:27:57 -07:00
// Verify that the request was retried
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-21 13:27:57 -07:00
expect ( mockSendMessageStream ) . toHaveBeenCalledTimes ( 3 ) ; // 1st query, 2nd query, retry of 2nd query
expect ( mockSendMessageStream ) . toHaveBeenNthCalledWith (
3 ,
'second query' ,
expect . any ( AbortSignal ) ,
expect . any ( String ) ,
) ;
} ) ;
2025-09-10 22:20:13 -07:00
} ) ;
it ( 'should process LoopDetected event after moving pending history to history' , async ( ) = > {
mockSendMessageStream . mockReturnValue (
( async function * ( ) {
yield {
type : ServerGeminiEventType . Content ,
value : 'Some response content' ,
} ;
yield {
type : ServerGeminiEventType . LoopDetected ,
} ;
} ) ( ) ,
) ;
const { result } = renderTestHook ( ) ;
await act ( async ( ) = > {
await result . current . submitQuery ( 'test query' ) ;
} ) ;
// Verify that the content was added to history before the loop detection dialog
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'gemini' ,
text : 'Some response content' ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
// Then verify loop detection confirmation request was set
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-09-10 22:20:13 -07:00
expect ( result . current . loopDetectionConfirmationRequest ) . not . toBeNull ( ) ;
} ) ;
} ) ;
} ) ;
2025-06-02 01:50:28 -07:00
} ) ;