1- import type { ChatCompletion } from 'openai/resources'
1+ import type { ChatCompletion , ChatCompletionFunctionTool , ChatCompletionTool } from 'openai/resources'
22import { describe , expect , it , vi } from 'vitest'
33import { createNativeMessageAdapter } from '../adapters/native'
44import { createMessageEngine } from '../core/engine'
@@ -10,6 +10,11 @@ const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({
1010const createTestMessageEngine = ( options : CreateMessageEngineOptions ) =>
1111 createMessageEngine ( createNativeMessageAdapter ( ) , options )
1212
13+ const isFunctionTool = ( tool : ChatCompletionTool ) : tool is ChatCompletionFunctionTool => tool . type === 'function'
14+
15+ const functionToolNames = ( tools : ChatCompletionTool [ ] = [ ] ) =>
16+ tools . filter ( isFunctionTool ) . map ( ( tool ) => tool . function . name )
17+
1318describe ( 'toolPlugin' , ( ) => {
1419 it ( 'injects and executes runtime tools before falling back to callTool' , async ( ) => {
1520 const runtimeCall = vi . fn ( ( ) => ( { result : 'runtime-result' } ) )
@@ -35,7 +40,7 @@ describe('toolPlugin', () => {
3540 const hasToolResult = requestBody . messages . some ( ( message ) => message . role === 'tool' )
3641
3742 if ( ! hasToolResult ) {
38- expect ( requestBody . tools ?. map ( ( tool ) => tool . function . name ) ) . toEqual ( [ 'runtime_lookup' ] )
43+ expect ( functionToolNames ( requestBody . tools ) ) . toEqual ( [ 'runtime_lookup' ] )
3944 return {
4045 id : 'tool-call' ,
4146 object : 'chat.completion' ,
@@ -203,7 +208,7 @@ describe('toolPlugin', () => {
203208 const hasToolResult = requestBody . messages . some ( ( message ) => message . role === 'tool' )
204209
205210 if ( ! hasToolResult ) {
206- expect ( requestBody . tools ?. map ( ( tool ) => tool . function . name ) ) . toEqual ( [ 'provided_tool' ] )
211+ expect ( functionToolNames ( requestBody . tools ) ) . toEqual ( [ 'provided_tool' ] )
207212
208213 return {
209214 id : 'provider-tool-call' ,
@@ -290,4 +295,154 @@ describe('toolPlugin', () => {
290295 } ) ,
291296 )
292297 } )
298+
299+ it ( 'keeps runtime tool handlers stable for the tool list sent to the model' , async ( ) => {
300+ const runtimeCall = vi . fn ( ( ) => 'runtime result' )
301+ const fallbackCall = vi . fn ( ( ) => 'fallback result' )
302+ const runtimeTool : RuntimeTool = {
303+ tool : {
304+ type : 'function' ,
305+ function : {
306+ name : 'volatile_runtime_tool' ,
307+ description : 'Runtime tool that is only available during request preparation' ,
308+ } ,
309+ } ,
310+ handler : runtimeCall ,
311+ }
312+ let getToolsCalls = 0
313+ const responseProvider = vi . fn < ResponseProvider > ( async ( requestBody ) => {
314+ const hasToolResult = requestBody . messages . some ( ( message ) => message . role === 'tool' )
315+
316+ if ( ! hasToolResult ) {
317+ expect ( functionToolNames ( requestBody . tools ) ) . toEqual ( [ 'volatile_runtime_tool' ] )
318+
319+ return {
320+ id : 'volatile-tool-call' ,
321+ object : 'chat.completion' ,
322+ created : Math . floor ( Date . now ( ) / 1000 ) ,
323+ model : 'mock' ,
324+ choices : [
325+ {
326+ index : 0 ,
327+ message : {
328+ role : 'assistant' ,
329+ content : '' ,
330+ tool_calls : [
331+ {
332+ id : 'call-volatile' ,
333+ type : 'function' ,
334+ function : {
335+ name : 'volatile_runtime_tool' ,
336+ arguments : '{}' ,
337+ } ,
338+ } ,
339+ ] ,
340+ } ,
341+ finish_reason : 'tool_calls' ,
342+ } ,
343+ ] ,
344+ } as ChatCompletion
345+ }
346+
347+ expect ( requestBody . messages . at ( - 1 ) ) . toMatchObject ( {
348+ role : 'tool' ,
349+ tool_call_id : 'call-volatile' ,
350+ content : 'runtime result' ,
351+ } )
352+
353+ return {
354+ id : 'final-answer' ,
355+ object : 'chat.completion' ,
356+ created : Math . floor ( Date . now ( ) / 1000 ) ,
357+ model : 'mock' ,
358+ choices : [
359+ {
360+ index : 0 ,
361+ message : {
362+ role : 'assistant' ,
363+ content : 'done' ,
364+ } ,
365+ finish_reason : 'stop' ,
366+ } ,
367+ ] ,
368+ } as ChatCompletion
369+ } )
370+
371+ const engine = createTestMessageEngine ( {
372+ plugins : [
373+ ...silentDefaultPlugins ,
374+ toolPlugin ( {
375+ getTools : async ( ) => {
376+ getToolsCalls ++
377+ return getToolsCalls === 1 ? [ runtimeTool ] : [ ]
378+ } ,
379+ callTool : fallbackCall ,
380+ } ) ,
381+ ] ,
382+ responseProvider,
383+ } )
384+
385+ await engine . sendMessage ( 'call volatile tool' )
386+
387+ expect ( runtimeCall ) . toHaveBeenCalledOnce ( )
388+ expect ( fallbackCall ) . not . toHaveBeenCalled ( )
389+ } )
390+
391+ it ( 'keeps custom tools already present on the request body' , async ( ) => {
392+ const customTool = {
393+ type : 'custom' ,
394+ custom : {
395+ name : 'custom_formatter' ,
396+ description : 'Format with custom grammar' ,
397+ format : {
398+ type : 'grammar' ,
399+ grammar : {
400+ syntax : 'lark' ,
401+ definition : 'start: "ok"' ,
402+ } ,
403+ } ,
404+ } ,
405+ } satisfies ChatCompletionTool
406+ const responseProvider = vi . fn < ResponseProvider > ( async ( requestBody ) => {
407+ expect ( requestBody . tools ) . toEqual ( [ customTool ] )
408+
409+ return {
410+ id : 'final-answer' ,
411+ object : 'chat.completion' ,
412+ created : Math . floor ( Date . now ( ) / 1000 ) ,
413+ model : 'mock' ,
414+ choices : [
415+ {
416+ index : 0 ,
417+ message : {
418+ role : 'assistant' ,
419+ content : 'done' ,
420+ } ,
421+ finish_reason : 'stop' ,
422+ } ,
423+ ] ,
424+ } as ChatCompletion
425+ } )
426+
427+ const engine = createTestMessageEngine ( {
428+ plugins : [
429+ ...silentDefaultPlugins ,
430+ {
431+ name : 'custom-tool-plugin' ,
432+ onBeforeRequest : ( context ) => {
433+ context . requestBody . tools = [ customTool ]
434+ } ,
435+ } ,
436+ toolPlugin ( {
437+ getTools : async ( ) => [ ] ,
438+ callTool : async ( ) => 'fallback' ,
439+ } ) ,
440+ ] ,
441+ responseProvider,
442+ } )
443+
444+ await engine . sendMessage ( 'use custom tool' )
445+
446+ expect ( responseProvider ) . toHaveBeenCalledOnce ( )
447+ } )
293448} )
0 commit comments